From a2be5b7f523f5851a09686e544f74f4af39136a0 Mon Sep 17 00:00:00 2001 From: WrBug Date: Thu, 26 Feb 2026 19:23:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(crypto-tail):=20=E4=BB=B7=E5=B7=AE?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E7=9B=91=E6=8E=A7=E9=A1=B5=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E4=B8=8B=E5=8D=95=E4=B8=8E=20UI=20=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:手动下单 API,trigger_type 区分 AUTO/MANUAL,价格 4 位小数向上取整 - 前端:监控页手动买入 Up/Down 按钮、二次确认弹窗、策略信息移动端适配 - 下单价格最多 4 位小数;确认页方向仅显示 Up/Down;按钮文案改为「买入 Up/Down」 Made-with: Cursor --- .../CryptoTailStrategyController.kt | 42 +++ .../dto/CryptoTailManualOrderRequest.kt | 21 ++ .../dto/CryptoTailManualOrderResponse.kt | 31 ++ .../entity/CryptoTailStrategyTrigger.kt | 3 + .../CryptoTailStrategyExecutionService.kt | 184 ++++++++- ...dd_trigger_type_to_crypto_tail_trigger.sql | 5 + deploy-interactive-README.md | 207 +++++++++- deploy-interactive.sh | 354 +++++++++--------- frontend/src/locales/en/common.json | 33 ++ frontend/src/locales/zh-CN/common.json | 33 ++ frontend/src/locales/zh-TW/common.json | 33 ++ frontend/src/pages/CryptoTailMonitor.tsx | 254 ++++++++++++- frontend/src/services/api.ts | 14 +- frontend/src/types/index.ts | 28 ++ 14 files changed, 1045 insertions(+), 197 deletions(-) create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderRequest.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderResponse.kt create mode 100644 backend/src/main/resources/db/migration/V39__add_trigger_type_to_crypto_tail_trigger.sql diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt index 40c30e9..4bc986f 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt @@ -13,10 +13,13 @@ import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto import com.wrbug.polymarketbot.dto.CryptoTailAutoMinSpreadResponse import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse +import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest +import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService +import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyExecutionService import org.slf4j.LoggerFactory import org.springframework.context.MessageSource import org.springframework.http.ResponseEntity @@ -24,12 +27,14 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import kotlinx.coroutines.runBlocking @RestController @RequestMapping("/api/crypto-tail-strategy") class CryptoTailStrategyController( private val cryptoTailStrategyService: CryptoTailStrategyService, private val cryptoTailMonitorService: CryptoTailMonitorService, + private val cryptoTailStrategyExecutionService: CryptoTailStrategyExecutionService, private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService, private val messageSource: MessageSource ) { @@ -216,4 +221,41 @@ class CryptoTailStrategyController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) } } + + /** + * 手动下单 + * 用户主动触发下单,不检查任何条件,仅检查当前周期是否已下单 + */ + @PostMapping("/manual-order") + fun manualOrder(@RequestBody request: CryptoTailManualOrderRequest): ResponseEntity> { + return runBlocking { + try { + if (request.strategyId <= 0) { + return@runBlocking ResponseEntity.ok( + ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource) + ) + } + val result = cryptoTailStrategyExecutionService.manualOrder(request) + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, + onFailure = { e -> + logger.error("手动下单失败: ${e.message}", e) + val code = when (e.message) { + "策略不存在" -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND + "当前周期已下单" -> ErrorCode.PARAM_ERROR + "价格必须在 0~1 之间" -> ErrorCode.PARAM_ERROR + "数量不能少于 1" -> ErrorCode.PARAM_ERROR + "总金额不能少于 1 USDC" -> ErrorCode.PARAM_ERROR + "总金额超过策略配置的投入金额" -> ErrorCode.PARAM_ERROR + else -> ErrorCode.SERVER_ERROR + } + ResponseEntity.ok(ApiResponse.error(code, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("手动下单异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderRequest.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderRequest.kt new file mode 100644 index 0000000..38074c7 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderRequest.kt @@ -0,0 +1,21 @@ +package com.wrbug.polymarketbot.dto + +/** + * 加密价差策略手动下单请求 + */ +data class CryptoTailManualOrderRequest( + /** 策略ID */ + val strategyId: Long = 0L, + /** 当前周期开始时间 (Unix 秒) */ + val periodStartUnix: Long = 0L, + /** 下单方向: UP or DOWN */ + val direction: String = "UP", + /** 下单价格 */ + val price: String = "0", + /** 下单数量 */ + val size: String = "1", + /** 市场标题(用于记录) */ + val marketTitle: String = "", + /** Token IDs */ + val tokenIds: List = emptyList() +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderResponse.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderResponse.kt new file mode 100644 index 0000000..12000ef --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderResponse.kt @@ -0,0 +1,31 @@ +package com.wrbug.polymarketbot.dto + +/** + * 加密价差策略手动下单响应 + */ +data class CryptoTailManualOrderResponse( + /** 是否成功 */ + val success: Boolean = false, + /** 订单ID */ + val orderId: String? = null, + /** 提示消息 */ + val message: String = "", + /** 下单详情 */ + val orderDetails: ManualOrderDetails? = null +) + +/** + * 手动下单详情 + */ +data class ManualOrderDetails( + /** 策略ID */ + val strategyId: Long = 0L, + /** 方向 */ + val direction: String = "", + /** 下单价格 */ + val price: String = "", + /** 下单数量 */ + val size: String = "", + /** 总金额 */ + val totalAmount: String = "" +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt index e8020df..472271e 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt @@ -56,6 +56,9 @@ data class CryptoTailStrategyTrigger( @Column(name = "fail_reason", length = 500) val failReason: String? = null, + @Column(name = "trigger_type", nullable = false, length = 20) + val triggerType: String = "AUTO", + @Column(name = "created_at", nullable = false) val createdAt: Long = System.currentTimeMillis(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt index 9119b1a..97478af 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt @@ -3,6 +3,9 @@ package com.wrbug.polymarketbot.service.cryptotail import com.wrbug.polymarketbot.api.GammaEventBySlugResponse import com.wrbug.polymarketbot.api.NewOrderRequest import com.wrbug.polymarketbot.api.PolymarketClobApi +import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest +import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse +import com.wrbug.polymarketbot.dto.ManualOrderDetails import com.wrbug.polymarketbot.entity.Account import com.wrbug.polymarketbot.entity.CryptoTailStrategy import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger @@ -411,7 +414,8 @@ class CryptoTailStrategyExecutionService( outcomeIndex, triggerPrice, amountUsdc, - orderRequest + orderRequest, + triggerType = "AUTO" ) return } @@ -427,7 +431,8 @@ class CryptoTailStrategyExecutionService( outcomeIndex: Int, triggerPrice: BigDecimal, amountUsdc: BigDecimal, - orderRequest: NewOrderRequest + orderRequest: NewOrderRequest, + triggerType: String = "AUTO" ) { var failReason: String? = null try { @@ -444,9 +449,10 @@ class CryptoTailStrategyExecutionService( amountUsdc, body.orderId, "success", - null + null, + triggerType = triggerType ) - logger.info("加密价差策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}") + logger.info("加密价差策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}, triggerType=$triggerType") return } failReason = body.errorMsg ?: "unknown" @@ -467,7 +473,8 @@ class CryptoTailStrategyExecutionService( amountUsdc, null, "fail", - failReason + failReason, + triggerType = triggerType ) logger.error("加密价差策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason") } @@ -650,7 +657,8 @@ class CryptoTailStrategyExecutionService( amountUsdc: BigDecimal, orderId: String?, status: String, - failReason: String? + failReason: String?, + triggerType: String = "AUTO" ) { val record = CryptoTailStrategyTrigger( strategyId = strategy.id!!, @@ -661,11 +669,173 @@ class CryptoTailStrategyExecutionService( amountUsdc = amountUsdc, orderId = orderId, status = status, - failReason = failReason + failReason = failReason, + triggerType = triggerType ) triggerRepository.save(record) } + /** + * 手动下单:用户主动触发下单,不检查任何条件,仅检查当前周期是否已下单 + */ + suspend fun manualOrder(request: CryptoTailManualOrderRequest): Result { + return try { + val strategy = strategyRepository.findById(request.strategyId).orElse(null) + ?: return Result.failure(IllegalArgumentException("策略不存在")) + + val outcomeIndex = if (request.direction.uppercase() == "UP") 0 else 1 + + if (outcomeIndex < 0 || outcomeIndex >= request.tokenIds.size) { + return Result.failure(IllegalArgumentException("outcomeIndex 越界")) + } + + val price = request.price.toSafeBigDecimal() + if (price <= BigDecimal.ZERO || price > BigDecimal.ONE) { + return Result.failure(IllegalArgumentException("价格必须在 0~1 之间")) + } + val priceRounded = price.setScale(4, RoundingMode.UP) + + val size = request.size.toSafeBigDecimal() + if (size < BigDecimal.ONE) { + return Result.failure(IllegalArgumentException("数量不能少于 1")) + } + + val amountUsdc = priceRounded.multi(size).setScale(2, RoundingMode.HALF_UP) + if (amountUsdc < BigDecimal.ONE) { + return Result.failure(IllegalArgumentException("总金额不能少于 1 USDC")) + } + + val mutex = getTriggerMutex(strategy.id!!, request.periodStartUnix) + mutex.withLock { + if (triggerRepository.findByStrategyIdAndPeriodStartUnix( + strategy.id!!, + request.periodStartUnix + ) != null + ) { + return@withLock Result.failure(IllegalArgumentException("当前周期已下单")) + } + + var ctx = getOrInvalidatePeriodContext(strategy, request.periodStartUnix) + if (ctx == null) { + ctx = ensurePeriodContext( + strategy, + request.periodStartUnix, + request.tokenIds, + request.marketTitle.ifBlank { null } + ) + } + if (ctx != null) { + val tokenId = request.tokenIds.getOrNull(outcomeIndex) + ?: return@withLock Result.failure(IllegalArgumentException("tokenIds 越界")) + + val priceStr = priceRounded.toPlainString() + val sizeStr = size.toPlainString() + val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0" + + val signedOrder = orderSigningService.createAndSignOrder( + privateKey = ctx.decryptedPrivateKey, + makerAddress = ctx.account.proxyAddress, + tokenId = tokenId, + side = "BUY", + price = priceStr, + size = sizeStr, + signatureType = ctx.signatureType, + nonce = "0", + feeRateBps = feeRateBps, + expiration = "0" + ) + + val orderRequest = NewOrderRequest( + order = signedOrder, + owner = ctx.account.apiKey!!, + orderType = "FAK", + deferExec = false + ) + + val orderResult = submitOrderForManualOrder( + ctx.clobApi, + strategy, + request.periodStartUnix, + request.marketTitle, + outcomeIndex, + priceRounded, + amountUsdc, + orderRequest + ) + + orderResult.fold( + onSuccess = { orderId -> + Result.success( + CryptoTailManualOrderResponse( + success = true, + orderId = orderId, + message = "下单成功", + orderDetails = ManualOrderDetails( + strategyId = strategy.id!!, + direction = request.direction, + price = priceStr, + size = sizeStr, + totalAmount = amountUsdc.toPlainString() + ) + ) + ) + }, + onFailure = { e -> + Result.failure(e) + } + ) + } else { + Result.failure(IllegalArgumentException("账户未配置或凭证不足")) + } + } + } catch (e: Exception) { + logger.error("手动下单异常: strategyId=${request.strategyId}, ${e.message}", e) + Result.failure(e) + } + } + + private suspend fun submitOrderForManualOrder( + clobApi: PolymarketClobApi, + strategy: CryptoTailStrategy, + periodStartUnix: Long, + marketTitle: String?, + outcomeIndex: Int, + price: BigDecimal, + amountUsdc: BigDecimal, + orderRequest: NewOrderRequest + ): Result { + return try { + val response = clobApi.createOrder(orderRequest) + if (response.isSuccessful && response.body() != null) { + val body = response.body()!! + if (body.success && body.orderId != null) { + saveTriggerRecord( + strategy, + periodStartUnix, + marketTitle, + outcomeIndex, + price, + amountUsdc, + body.orderId, + "success", + null, + triggerType = "MANUAL" + ) + logger.info("手动下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}") + Result.success(body.orderId) + } else { + Result.failure(Exception(body.errorMsg ?: "unknown")) + } + } else { + val errorBody = response.errorBody()?.string().orEmpty() + Result.failure(Exception(errorBody.ifEmpty { "请求失败" })) + } + } catch (e: Exception) { + logger.error("手动下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e) + Result.failure(e) + } + } + @PreDestroy fun destroy() { // 清理所有周期上下文缓存,避免敏感信息(明文私钥、API Secret)在内存中保留 diff --git a/backend/src/main/resources/db/migration/V39__add_trigger_type_to_crypto_tail_trigger.sql b/backend/src/main/resources/db/migration/V39__add_trigger_type_to_crypto_tail_trigger.sql new file mode 100644 index 0000000..60df806 --- /dev/null +++ b/backend/src/main/resources/db/migration/V39__add_trigger_type_to_crypto_tail_trigger.sql @@ -0,0 +1,5 @@ +-- 添加触发类型字段到加密价差策略触发记录表 +-- AUTO: 自动下单触发 +-- MANUAL: 手动下单触发 +ALTER TABLE crypto_tail_strategy_trigger +ADD COLUMN trigger_type VARCHAR(20) DEFAULT 'AUTO' COMMENT '触发类型:AUTO(自动)或 MANUAL(手动)'; diff --git a/deploy-interactive-README.md b/deploy-interactive-README.md index de58e32..eb953d2 100644 --- a/deploy-interactive-README.md +++ b/deploy-interactive-README.md @@ -1,4 +1,209 @@ -# PolyHermes 一键部署脚本使用说明 +# PolyHermes One-Click Deployment Script / PolyHermes 一键部署脚本使用说明 + +[English](#english) | [中文](#中文) + +--- + + +## English + +## ✨ Core Features + +- **Run from any directory** - No need to download source code +- **Online images only** - Pull official images from Docker Hub +- **Auto-download config** - Download the latest `docker-compose.prod.yml` from GitHub +- **Interactive configuration** - User-friendly Q&A style configuration wizard +- **Auto-generate secrets** - All sensitive configurations will auto-generate secure random values on Enter + +## 🚀 Quick Start + +### One-Click Installation (Recommended) + +**Using curl (Recommended):** +```bash +mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh +``` + +**Using wget:** +```bash +mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh +``` + +This command will automatically: +- 📁 Create a dedicated working directory `~/polyhermes` +- 📥 Download the deployment script +- ✅ Check Docker environment +- ⚙️ Configure all parameters interactively (press Enter for defaults) +- 🔐 Auto-generate secure random secrets +- 🚀 Download latest images and deploy + +**Or run directly via pipe (without saving file):** +```bash +# curl method +mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash + +# wget method +mkdir -p ~/polyhermes && cd ~/polyhermes && wget -qO- https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash +``` + +### Method 1: Download and Run Script Directly + +```bash +# Download script +curl -O https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh + +# Add execute permission +chmod +x deploy-interactive.sh + +# Run +./deploy-interactive.sh +``` + +### Method 2: Run in Project Directory + +```bash +git clone https://github.com/WrBug/PolyHermes.git +cd PolyHermes +./deploy-interactive.sh +``` + +## 📝 Usage Flow + +After running the script, you will be guided through the following steps: + +``` +Step 1: Environment Check → Check Docker/Docker Compose +Step 2: Configuration → Interactive input (press Enter for defaults) +Step 3: Get Deploy Config → Download docker-compose.prod.yml from GitHub +Step 4: Generate Env File → Auto-generate .env +Step 5: Pull Docker Images → Pull latest images from Docker Hub +Step 6: Deploy Services → Start containers +Step 7: Health Check → Verify services are running properly +``` + +## ⚡ Simplest Usage + +**Press Enter for all configuration items to use default values**, the script will automatically: +- Use port 80 (application) and 3307 (MySQL) +- Generate 32-character database password +- Generate 128-character JWT secret +- Generate 64-character admin reset key +- Generate 64-character encryption key +- Configure reasonable log levels + +### Interactive Example + +The script will prompt you for configuration one by one, **press Enter to skip and use default values**: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Step 2: Configuration +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +💡 All configurations are optional, press Enter to use default or auto-generated values + +⚠ Secret config: Press Enter to auto-generate secure random secrets +⚠ Other config: Press Enter to use default values in parentheses + +【Basic Configuration】 +Will configure: Server port, MySQL port, Timezone +➤ Server port [Default: 80]: ⏎ +➤ MySQL port (external access) [Default: 3307]: ⏎ +➤ Timezone [Default: Asia/Shanghai]: ⏎ + +【Database Configuration】 +Will configure: Database username, Database password +➤ Database username [Default: root]: ⏎ +➤ Database password [Enter to auto-generate]: ⏎ +[✓] Database password auto-generated (32 characters) + +【Security Configuration】 +Will configure: JWT secret, Admin password reset key, Data encryption key +➤ JWT secret [Enter to auto-generate]: ⏎ +[✓] JWT secret auto-generated (128 characters) +➤ Admin password reset key [Enter to auto-generate]: ⏎ +[✓] Admin reset key auto-generated (64 characters) +➤ Encryption key (for API Key encryption) [Enter to auto-generate]: ⏎ +[✓] Encryption key auto-generated (64 characters) + +【Log Configuration】 +Will configure: Root log level, Application log level +Available levels: TRACE, DEBUG, INFO, WARN, ERROR, OFF +➤ Root log level (third-party libs) [Default: WARN]: ⏎ +➤ Application log level [Default: INFO]: ⏎ + +【Other Configuration】 +Will configure: Runtime environment, Auto-update policy, GitHub repo +➤ Spring Profile [Default: prod]: ⏎ +➤ Allow prerelease updates (true/false) [Default: false]: ⏎ +➤ GitHub repository [Default: WrBug/PolyHermes]: ⏎ +``` + +## 🔧 Files Generated by Script + +After running, the script will generate in the current directory: + +1. **docker-compose.prod.yml** - Docker Compose config downloaded from GitHub (always latest) +2. **.env** - Environment variables file auto-generated based on your configuration + +These two files contain all the configuration needed to run PolyHermes. + +## 🌐 Post-Deployment Management + +### Quick Update (Recommended) + +If you already have configuration files, running the script again will detect and ask: + +```bash +./deploy-interactive.sh +``` + +``` +【Existing Configuration Detected】 +Found existing .env configuration file + +Use existing configuration to update images directly? [Y/n]: ⏎ +``` + +- **Press Enter or input Y**: Use existing config, pull latest images and update +- **Input N**: Reconfigure (existing config will be backed up) + +### Manual Management Commands + +```bash +# View service status +docker compose -f docker-compose.prod.yml ps + +# View logs +docker compose -f docker-compose.prod.yml logs -f + +# Restart services +docker compose -f docker-compose.prod.yml restart + +# Stop services +docker compose -f docker-compose.prod.yml down + +# Update to latest version +docker pull wrbug/polyhermes:latest +docker compose -f docker-compose.prod.yml up -d +``` + +## 🔐 Security Recommendations + +- **Protect .env file**: Contains sensitive information, never commit to version control +- **Backup database regularly**: Data is stored in Docker volume `mysql-data` +- **Configure HTTPS for production**: Recommend using Nginx or Caddy as reverse proxy + +## 📞 Support + +- [GitHub Repository](https://github.com/WrBug/PolyHermes) +- [Issue Feedback](https://github.com/WrBug/PolyHermes/issues) +- [Full Deployment Documentation](docs/zh/DEPLOYMENT_GUIDE.md) + +--- + + +## 中文 ## ✨ 核心特性 diff --git a/deploy-interactive.sh b/deploy-interactive.sh index 7ac6d19..ec0981e 100755 --- a/deploy-interactive.sh +++ b/deploy-interactive.sh @@ -1,18 +1,19 @@ #!/bin/bash # ======================================== +# PolyHermes Interactive Deploy Script # PolyHermes 交互式一键部署脚本 # ======================================== -# 功能: -# - 交互式配置环境变量 -# - 自动生成安全密钥 -# - 使用 Docker Hub 线上镜像部署 -# - 支持配置预检和回滚 +# Features / 功能: +# - Interactive env config / 交互式配置环境变量 +# - Auto-generate secrets / 自动生成安全密钥 +# - Deploy via Docker Hub images / 使用 Docker Hub 线上镜像部署 +# - Config check and rollback / 支持配置预检和回滚 # ======================================== set -e -# 颜色输出 +# Colors / 颜色输出 RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' @@ -20,7 +21,14 @@ BLUE='\033[0;34m' CYAN='\033[0;36m' NC='\033[0m' # No Color -# 打印函数 +# Language: LANG=zh* → prompts in Chinese only; else show "中文 / English" +# 语言:LANG 为 zh* 时仅中文,否则显示「中文 / English」 +USE_ZH_ONLY=false +case "${LANG:-}" in + zh*) USE_ZH_ONLY=true ;; +esac + +# Print functions / 打印函数 info() { echo -e "${GREEN}[✓]${NC} $1" } @@ -37,6 +45,17 @@ title() { echo -e "${CYAN}${1}${NC}" } +# Bilingual: 中文 / English (or Chinese only when LANG=zh*) +bilingual() { + local zh="$1" + local en="$2" + if [ "$USE_ZH_ONLY" = true ]; then + echo "$zh" + else + echo "$zh / $en" + fi +} + # 生成随机密钥 generate_secret() { local length=${1:-32} @@ -47,54 +66,57 @@ generate_secret() { fi } -# 生成随机端口号(10000-60000之间) +# 生成随机端口号(10000-60000之间)/ Generate random port (10000-60000) generate_random_port() { echo $((10000 + RANDOM % 50001)) } -# 读取用户输入(支持默认值) +# 读取用户输入(支持默认值)/ Read user input (with default) read_input() { local prompt="$1" local default="$2" local is_secret="$3" local value="" - # 构建提示信息(不使用颜色,因为 read -p 可能不支持) local prompt_text="" if [ -n "$default" ]; then if [ "$is_secret" = "secret" ]; then - prompt_text="${prompt} [回车自动生成]: " + if [ "$USE_ZH_ONLY" = true ]; then + prompt_text="${prompt} [回车自动生成]: " + else + prompt_text="${prompt} [Enter to auto-generate]: " + fi else - prompt_text="${prompt} [默认: ${default}]: " + if [ "$USE_ZH_ONLY" = true ]; then + prompt_text="${prompt} [默认: ${default}]: " + else + prompt_text="${prompt} [Default: ${default}]: " + fi fi else prompt_text="${prompt}: " fi - # 使用 read -p 确保提示正确显示 read -r -p "$prompt_text" value - # 如果用户没有输入,使用默认值 if [ -z "$value" ]; then if [ "$is_secret" = "secret" ] && [ -z "$default" ]; then - # 自动生成密钥 case "$prompt" in - *JWT*) + *JWT*|*jwt*) value=$(generate_secret 64) - # 输出到 stderr,避免被捕获到返回值中 - info "已自动生成 JWT 密钥(128字符)" >&2 + info "$(bilingual "已自动生成 JWT 密钥(128字符)" "JWT secret auto-generated (128 chars)")" >&2 ;; - *管理员*|*ADMIN*) + *管理员*|*ADMIN*|*admin*|*reset*|*Reset*) value=$(generate_secret 32) - info "已自动生成管理员重置密钥(64字符)" >&2 + info "$(bilingual "已自动生成管理员重置密钥(64字符)" "Admin reset key auto-generated (64 chars)")" >&2 ;; - *加密*|*CRYPTO*) + *加密*|*CRYPTO*|*crypto*|*Encryption*) value=$(generate_secret 32) - info "已自动生成加密密钥(64字符)" >&2 + info "$(bilingual "已自动生成加密密钥(64字符)" "Encryption key auto-generated (64 chars)")" >&2 ;; - *数据库密码*|*DB_PASSWORD*) + *数据库密码*|*DB_PASSWORD*|*database*|*Database*) value=$(generate_secret 16) - info "已自动生成数据库密码(32字符)" >&2 + info "$(bilingual "已自动生成数据库密码(32字符)" "Database password auto-generated (32 chars)")" >&2 ;; *) value="$default" @@ -108,126 +130,115 @@ read_input() { echo "$value" } -# 检查 Docker 环境 +# 检查 Docker 环境 / Check Docker environment check_docker() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 1: 环境检查" + title " $(bilingual "步骤 1: 环境检查" "Step 1: Environment Check")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - # 检查 Docker if ! command -v docker &> /dev/null; then - error "Docker 未安装" + error "$(bilingual "Docker 未安装" "Docker is not installed")" echo "" - info "请先安装 Docker:" + info "$(bilingual "请先安装 Docker:" "Please install Docker first:")" info " macOS: brew install docker" info " Ubuntu/Debian: apt-get install docker.io" info " CentOS/RHEL: yum install docker" exit 1 fi - info "Docker 已安装: $(docker --version | head -1)" + info "$(bilingual "Docker 已安装" "Docker installed"): $(docker --version | head -1)" - # 检查 Docker Compose if docker compose version &> /dev/null 2>&1; then - info "Docker Compose 已安装: $(docker compose version)" + info "$(bilingual "Docker Compose 已安装" "Docker Compose installed"): $(docker compose version)" elif command -v docker-compose &> /dev/null; then - info "Docker Compose 已安装: $(docker-compose --version)" + info "$(bilingual "Docker Compose 已安装" "Docker Compose installed"): $(docker-compose --version)" else - error "Docker Compose 未安装" + error "$(bilingual "Docker Compose 未安装" "Docker Compose is not installed")" echo "" - info "请先安装 Docker Compose:" + info "$(bilingual "请先安装 Docker Compose:" "Please install Docker Compose:")" info " https://docs.docker.com/compose/install/" exit 1 fi - # 检查 Docker 守护进程 if ! docker info &> /dev/null; then - error "Docker 守护进程未运行" - info "请启动 Docker 服务:" - info " macOS: 打开 Docker Desktop" + error "$(bilingual "Docker 守护进程未运行" "Docker daemon is not running")" + info "$(bilingual "请启动 Docker 服务:" "Please start Docker:")" + info " $(bilingual "macOS: 打开 Docker Desktop" "macOS: Open Docker Desktop")" info " Linux: systemctl start docker" exit 1 fi - info "Docker 守护进程运行正常" + info "$(bilingual "Docker 守护进程运行正常" "Docker daemon is running")" echo "" } -# 交互式配置收集 +# 交互式配置收集 / Interactive configuration collect_configuration() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 2: 配置收集" + title " $(bilingual "步骤 2: 配置收集" "Step 2: Configuration")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" - info "💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成" + info "$(bilingual "💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成" "💡 All options are optional, press Enter for default or auto-generated values")" echo "" - warn "密钥配置:回车将自动生成安全的随机密钥" - warn "其他配置:回车将使用括号中的默认值" + warn "$(bilingual "密钥配置:回车将自动生成安全的随机密钥" "Secrets: Enter to auto-generate secure random keys")" + warn "$(bilingual "其他配置:回车将使用括号中的默认值" "Other: Enter to use default value in brackets")" echo "" - # 基础配置 - title "【基础配置】" - echo -e "${CYAN}将配置:服务器端口、MySQL端口、时区${NC}" - # 生成随机端口作为默认值 + title "$(bilingual "【基础配置】" "【Basic】")" + echo -e "${CYAN}$(bilingual "将配置:服务器端口、MySQL端口、时区" "Server port, MySQL port, Timezone")${NC}" DEFAULT_PORT=$(generate_random_port) - SERVER_PORT=$(read_input "➤ 服务器端口" "$DEFAULT_PORT") - MYSQL_PORT=$(read_input "➤ MySQL 端口(外部访问)" "3307") - TZ=$(read_input "➤ 时区" "Asia/Shanghai") + SERVER_PORT=$(read_input "$(bilingual "➤ 服务器端口" "➤ Server port")" "$DEFAULT_PORT") + MYSQL_PORT=$(read_input "$(bilingual "➤ MySQL 端口(外部访问)" "➤ MySQL port (external)")" "3307") + TZ=$(read_input "$(bilingual "➤ 时区" "➤ Timezone")" "Asia/Shanghai") echo "" - # 数据库配置 - title "【数据库配置】" - echo -e "${CYAN}将配置:数据库用户名、数据库密码${NC}" - echo -e "${YELLOW}💡 提示:密码留空将自动生成 32 字符的安全随机密码${NC}" - DB_USERNAME=$(read_input "➤ 数据库用户名" "root") - DB_PASSWORD=$(read_input "➤ 数据库密码" "" "secret") + title "$(bilingual "【数据库配置】" "【Database】")" + echo -e "${CYAN}$(bilingual "将配置:数据库用户名、数据库密码" "Database username, password")${NC}" + echo -e "${YELLOW}$(bilingual "💡 提示:密码留空将自动生成 32 字符的安全随机密码" "💡 Leave password empty to auto-generate 32-char password")${NC}" + DB_USERNAME=$(read_input "$(bilingual "➤ 数据库用户名" "➤ Database username")" "root") + DB_PASSWORD=$(read_input "$(bilingual "➤ 数据库密码" "➤ Database password")" "" "secret") echo "" - # 安全配置 - title "【安全配置】" - echo -e "${CYAN}将配置:JWT密钥、管理员密码重置密钥、数据加密密钥${NC}" - echo -e "${YELLOW}💡 提示:留空将自动生成高强度随机密钥(推荐)${NC}" - JWT_SECRET=$(read_input "➤ JWT 密钥" "" "secret") - ADMIN_RESET_PASSWORD_KEY=$(read_input "➤ 管理员密码重置密钥" "" "secret") - CRYPTO_SECRET_KEY=$(read_input "➤ 加密密钥(用于加密 API Key)" "" "secret") + title "$(bilingual "【安全配置】" "【Security】")" + echo -e "${CYAN}$(bilingual "将配置:JWT密钥、管理员密码重置密钥、数据加密密钥" "JWT secret, Admin reset key, Encryption key")${NC}" + echo -e "${YELLOW}$(bilingual "💡 提示:留空将自动生成高强度随机密钥(推荐)" "💡 Leave empty to auto-generate strong keys (recommended)")${NC}" + JWT_SECRET=$(read_input "$(bilingual "➤ JWT 密钥" "➤ JWT secret")" "" "secret") + ADMIN_RESET_PASSWORD_KEY=$(read_input "$(bilingual "➤ 管理员密码重置密钥" "➤ Admin password reset key")" "" "secret") + CRYPTO_SECRET_KEY=$(read_input "$(bilingual "➤ 加密密钥(用于加密 API Key)" "➤ Encryption key (for API Key)")" "" "secret") echo "" - # 日志配置 - title "【日志配置】" - echo -e "${CYAN}将配置:Root日志级别、应用日志级别${NC}" - echo -e "${YELLOW}可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF${NC}" - LOG_LEVEL_ROOT=$(read_input "➤ Root 日志级别(第三方库)" "WARN") - LOG_LEVEL_APP=$(read_input "➤ 应用日志级别" "INFO") + title "$(bilingual "【日志配置】" "【Logging】")" + echo -e "${CYAN}$(bilingual "将配置:Root日志级别、应用日志级别" "Root log level, App log level")${NC}" + echo -e "${YELLOW}$(bilingual "可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF" "Levels: TRACE, DEBUG, INFO, WARN, ERROR, OFF")${NC}" + LOG_LEVEL_ROOT=$(read_input "$(bilingual "➤ Root 日志级别(第三方库)" "➤ Root log level (3rd party)")" "WARN") + LOG_LEVEL_APP=$(read_input "$(bilingual "➤ 应用日志级别" "➤ App log level")" "INFO") echo "" - # 自动设置不需要用户输入的配置 SPRING_PROFILES_ACTIVE="prod" ALLOW_PRERELEASE="false" GITHUB_REPO="WrBug/PolyHermes" } -# 下载 docker-compose.prod.yml(如果不存在) +# 下载 docker-compose.prod.yml(如果不存在)/ Download docker-compose.prod.yml if missing download_docker_compose_file() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 3: 获取部署配置" + title " $(bilingual "步骤 3: 获取部署配置" "Step 3: Get Deploy Config")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if [ -f "docker-compose.prod.yml" ]; then - info "检测到现有 docker-compose.prod.yml,跳过下载" + info "$(bilingual "检测到现有 docker-compose.prod.yml,跳过下载" "Existing docker-compose.prod.yml found, skip download")" echo "" return 0 fi - info "正在从 GitHub 下载 docker-compose.prod.yml..." + info "$(bilingual "正在从 GitHub 下载 docker-compose.prod.yml..." "Downloading docker-compose.prod.yml from GitHub...")" - # GitHub raw 文件链接 local compose_url="https://raw.githubusercontent.com/WrBug/PolyHermes/main/docker-compose.prod.yml" - # 尝试下载 if curl -fsSL "$compose_url" -o docker-compose.prod.yml; then - info "docker-compose.prod.yml 下载成功" + info "$(bilingual "docker-compose.prod.yml 下载成功" "docker-compose.prod.yml downloaded")" else - error "docker-compose.prod.yml 下载失败" - warn "请检查网络连接或手动下载:" + error "$(bilingual "docker-compose.prod.yml 下载失败" "Failed to download docker-compose.prod.yml")" + warn "$(bilingual "请检查网络连接或手动下载:" "Check network or download manually:")" warn " $compose_url" exit 1 fi @@ -235,28 +246,26 @@ download_docker_compose_file() { echo "" } -# 生成 .env 文件 +# 生成 .env 文件 / Generate .env file generate_env_file() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 4: 生成环境变量文件" + title " $(bilingual "步骤 4: 生成环境变量文件" "Step 4: Generate .env")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - # 备份现有 .env 文件 if [ -f ".env" ]; then BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)" cp .env "$BACKUP_FILE" - warn "已备份现有配置文件到: $BACKUP_FILE" + warn "$(bilingual "已备份现有配置文件到" "Backed up existing config to"): $BACKUP_FILE" fi - # 生成新的 .env 文件 cat > .env </dev/null | grep -q .; then - warn "检测到正在运行的服务,正在停止..." + warn "$(bilingual "检测到正在运行的服务,正在停止..." "Stopping existing services...")" docker compose -f docker-compose.prod.yml down - info "已停止现有服务" + info "$(bilingual "已停止现有服务" "Stopped existing services")" fi - # 启动服务 - info "正在启动服务..." + info "$(bilingual "正在启动服务..." "Starting services...")" if docker compose -f docker-compose.prod.yml up -d; then - info "服务启动成功" + info "$(bilingual "服务启动成功" "Services started")" else - error "服务启动失败" - error "请检查日志: docker compose -f docker-compose.prod.yml logs" + error "$(bilingual "服务启动失败" "Failed to start services")" + error "$(bilingual "请检查日志" "Check logs"): docker compose -f docker-compose.prod.yml logs" exit 1 fi echo "" } -# 健康检查 +# 健康检查 / Health check health_check() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 7: 健康检查" + title " $(bilingual "步骤 7: 健康检查" "Step 7: Health Check")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - info "等待服务启动(最多等待 60 秒)..." + info "$(bilingual "等待服务启动(最多等待 60 秒)..." "Waiting for services (up to 60s)...")" local max_attempts=12 local attempt=0 @@ -377,13 +381,11 @@ health_check() { while [ $attempt -lt $max_attempts ]; do attempt=$((attempt + 1)) - # 检查容器状态 if docker compose -f docker-compose.prod.yml ps | grep -q "Up"; then - info "容器运行正常" + info "$(bilingual "容器运行正常" "Containers are up")" - # 检查应用是否响应 if curl -s -o /dev/null -w "%{http_code}" http://localhost:${SERVER_PORT} | grep -q "200\|302\|401"; then - info "应用响应正常" + info "$(bilingual "应用响应正常" "App is responding")" echo "" return 0 fi @@ -394,79 +396,76 @@ health_check() { done echo "" - warn "健康检查超时,请手动检查服务状态" - warn "查看日志: docker compose -f docker-compose.prod.yml logs -f" + warn "$(bilingual "健康检查超时,请手动检查服务状态" "Health check timeout, please check services manually")" + warn "$(bilingual "查看日志" "View logs"): docker compose -f docker-compose.prod.yml logs -f" echo "" } -# 显示部署信息 +# 显示部署信息 / Show deployment info show_deployment_info() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 部署完成!" + title " $(bilingual "部署完成!" "Deployment Complete!")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" - info "访问地址: ${GREEN}http://localhost:${SERVER_PORT}${NC}" + info "$(bilingual "访问地址" "Access URL"): ${GREEN}http://localhost:${SERVER_PORT}${NC}" echo "" - title "【常用命令】" - echo -e " 查看服务状态: ${CYAN}docker compose -f docker-compose.prod.yml ps${NC}" - echo -e " 查看日志: ${CYAN}docker compose -f docker-compose.prod.yml logs -f${NC}" - echo -e " 停止服务: ${CYAN}docker compose -f docker-compose.prod.yml down${NC}" - echo -e " 重启服务: ${CYAN}docker compose -f docker-compose.prod.yml restart${NC}" - echo -e " 更新镜像: ${CYAN}docker pull wrbug/polyhermes:latest && docker compose -f docker-compose.prod.yml up -d${NC}" + title "$(bilingual "【常用命令】" "【Common Commands】")" + echo -e " $(bilingual "查看服务状态" "Status"): ${CYAN}docker compose -f docker-compose.prod.yml ps${NC}" + echo -e " $(bilingual "查看日志" "Logs"): ${CYAN}docker compose -f docker-compose.prod.yml logs -f${NC}" + echo -e " $(bilingual "停止服务" "Stop"): ${CYAN}docker compose -f docker-compose.prod.yml down${NC}" + echo -e " $(bilingual "重启服务" "Restart"): ${CYAN}docker compose -f docker-compose.prod.yml restart${NC}" + echo -e " $(bilingual "更新镜像" "Update"): ${CYAN}docker pull wrbug/polyhermes:latest && docker compose -f docker-compose.prod.yml up -d${NC}" echo "" - title "【数据库连接信息】" - echo -e " 主机: ${CYAN}localhost${NC}" - echo -e " 端口: ${CYAN}${MYSQL_PORT}${NC}" - echo -e " 数据库: ${CYAN}polyhermes${NC}" - echo -e " 用户名: ${CYAN}${DB_USERNAME}${NC}" - echo -e " 密码: ${CYAN}${DB_PASSWORD}${NC}" + title "$(bilingual "【数据库连接信息】" "【Database Connection】")" + echo -e " $(bilingual "主机" "Host"): ${CYAN}localhost${NC}" + echo -e " $(bilingual "端口" "Port"): ${CYAN}${MYSQL_PORT}${NC}" + echo -e " $(bilingual "数据库" "Database"): ${CYAN}polyhermes${NC}" + echo -e " $(bilingual "用户名" "Username"): ${CYAN}${DB_USERNAME}${NC}" + echo -e " $(bilingual "密码" "Password"): ${CYAN}${DB_PASSWORD}${NC}" echo "" - title "【管理员重置密钥】" - echo -e " 重置密钥: ${CYAN}${ADMIN_RESET_PASSWORD_KEY}${NC}" - echo -e " ${YELLOW}💡 此密钥用于重置管理员密码,请妥善保管${NC}" + title "$(bilingual "【管理员重置密钥】" "【Admin Reset Key】")" + echo -e " $(bilingual "重置密钥" "Reset key"): ${CYAN}${ADMIN_RESET_PASSWORD_KEY}${NC}" + echo -e " ${YELLOW}$(bilingual "💡 此密钥用于重置管理员密码,请妥善保管" "💡 Keep this key safe; it is used to reset admin password")${NC}" echo "" - warn "重要提示:" - warn " 1. 请妥善保管 .env 文件,勿提交到版本控制系统" - warn " 2. 定期备份数据库数据(位于 Docker volume: polyhermes_mysql-data)" - warn " 3. 生产环境建议配置反向代理(如 Nginx)并启用 HTTPS" + warn "$(bilingual "重要提示:" "Important:")" + warn " 1. $(bilingual "请妥善保管 .env 文件,勿提交到版本控制系统" "Keep .env secure; do not commit to version control")" + warn " 2. $(bilingual "定期备份数据库数据(位于 Docker volume: polyhermes_mysql-data)" "Back up DB regularly (Docker volume: polyhermes_mysql-data)")" + warn " 3. $(bilingual "生产环境建议配置反向代理(如 Nginx)并启用 HTTPS" "Use a reverse proxy (e.g. Nginx) and HTTPS in production")" echo "" } -# 主函数 +# 主函数 / Main main() { clear echo "" title "=========================================" - title " PolyHermes 交互式一键部署脚本 " + title " $(bilingual "PolyHermes 交互式一键部署脚本" "PolyHermes Interactive Deploy") " title "=========================================" echo "" - # 执行部署流程 check_docker - # 检查是否已存在 .env 文件 if [ -f ".env" ]; then echo "" - title "【检测到现有配置】" - info "发现已存在的 .env 配置文件" + title "$(bilingual "【检测到现有配置】" "【Existing Config Found】")" + info "$(bilingual "发现已存在的 .env 配置文件" "Found existing .env file")" echo "" - echo -ne "${YELLOW}是否使用现有配置直接更新镜像?[Y/n]: ${NC}" + echo -ne "${YELLOW}$(bilingual "是否使用现有配置直接更新镜像?[Y/n]" "Use existing config to update images? [Y/n]"): ${NC}" read -r use_existing use_existing=${use_existing:-Y} if [[ "$use_existing" =~ ^[Yy]$ ]]; then - info "将使用现有配置,跳过配置步骤" + info "$(bilingual "将使用现有配置,跳过配置步骤" "Using existing config, skipping configuration")" echo "" - # 从现有 .env 文件读取必要的变量 source .env 2>/dev/null || true else - warn "将重新配置,现有配置将被备份" + warn "$(bilingual "将重新配置,现有配置将被备份" "Will reconfigure; existing config will be backed up")" echo "" collect_configuration fi @@ -476,21 +475,18 @@ main() { download_docker_compose_file - # 只有在重新配置时才生成新的 .env 文件 if [[ ! "$use_existing" =~ ^[Yy]$ ]] || [ ! -f ".env" ]; then generate_env_file fi - # 确认部署 echo "" - title "【确认部署】" - echo -ne "${YELLOW}是否开始部署?[Y/n](回车默认为是): ${NC}" + title "$(bilingual "【确认部署】" "【Confirm Deploy】")" + echo -ne "${YELLOW}$(bilingual "是否开始部署?[Y/n](回车默认为是)" "Start deployment? [Y/n] (Enter = Yes)"): ${NC}" read -r confirm - # 默认为 Y,只有明确输入 n/N 才取消 confirm=${confirm:-Y} if [[ "$confirm" =~ ^[Nn]$ ]]; then - warn "部署已取消" + warn "$(bilingual "部署已取消" "Deployment cancelled")" exit 0 fi @@ -500,11 +496,11 @@ main() { health_check show_deployment_info - info "部署流程已完成!" + info "$(bilingual "部署流程已完成!" "Deployment finished!")" } -# 捕获 Ctrl+C -trap 'echo ""; warn "部署已中断"; exit 1' INT +# 捕获 Ctrl+C / Handle Ctrl+C +trap 'echo ""; warn "$(bilingual "部署已中断" "Deployment interrupted")"; exit 1' INT -# 运行主函数 +# 运行主函数 / Run main main "$@" diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index bd68b3c..0d007d6 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -1644,6 +1644,39 @@ "switchToLatest": "Switch to Latest Period", "periodEnded": "Current period has ended", "newPeriodAvailable": "New period has started" + }, + "manualOrder": { + "title": "Manual Order", + "buttonUp": "Buy Up", + "buttonDown": "Buy Down", + "confirmTitle": "Manual Order Confirmation", + "marketTitle": "Market Title", + "direction": "Direction", + "directionUp": "Up", + "directionDown": "Down", + "orderPrice": "Order Price", + "orderSize": "Order Size", + "totalAmount": "Total Amount", + "account": "Account", + "cancel": "Cancel", + "confirm": "Confirm Order", + "orderUnit": "USDC", + "sizeUnit": "shares", + "statusNotOrdered": "Not Ordered", + "statusOrdered": "This period has been ordered", + "statusOrderedAuto": "This period has been auto-ordered", + "statusOrderedManual": "This period has been manually ordered", + "errorInsufficientBalance": "Insufficient account balance, available: {balance} USDC", + "errorPriceOutOfRange": "Price must be between 0 and 1", + "errorMinSize": "Size cannot be less than 1 share", + "errorExceedsBalance": "Total amount exceeds available balance", + "errorPriceRequired": "Please enter order price", + "errorSizeRequired": "Please enter order size", + "success": "Manual order successful", + "failed": "Manual order failed: {{reason}}", + "errorSigning": "Signing failed: {reason}", + "errorNetwork": "Network error, please try again later", + "priceNotLoaded": "Price data not loaded" } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 8f442c8..fd764cf 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -1644,6 +1644,39 @@ "switchToLatest": "切换到最新周期", "periodEnded": "当前周期已结束", "newPeriodAvailable": "新周期已开始" + }, + "manualOrder": { + "title": "手动下单", + "buttonUp": "买入 Up", + "buttonDown": "买入 Down", + "confirmTitle": "手动下单确认", + "marketTitle": "市场标题", + "direction": "方向", + "directionUp": "Up", + "directionDown": "Down", + "orderPrice": "下单价格", + "orderSize": "下单数量", + "totalAmount": "总金额", + "account": "账户", + "cancel": "取消", + "confirm": "确认下单", + "orderUnit": "USDC", + "sizeUnit": "张", + "statusNotOrdered": "未下单", + "statusOrdered": "本周期已下单", + "statusOrderedAuto": "本周期已自动下单", + "statusOrderedManual": "本周期已手动下单", + "errorInsufficientBalance": "账户余额不足,可用余额:{{balance}} USDC", + "errorPriceOutOfRange": "价格必须在 0~1 之间", + "errorMinSize": "数量不能少于 1 张", + "errorExceedsBalance": "总金额超过可用余额", + "errorPriceRequired": "请输入下单价格", + "errorSizeRequired": "请输入下单数量", + "success": "手动下单成功", + "failed": "手动下单失败:{{reason}}", + "errorSigning": "签名失败:{{reason}}", + "errorNetwork": "网络错误,请稍后重试", + "priceNotLoaded": "价格数据未加载" } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index a0d8fd2..1d3650b 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -1644,6 +1644,39 @@ "switchToLatest": "切換到最新週期", "periodEnded": "當前週期已結束", "newPeriodAvailable": "新週期已開始" + }, + "manualOrder": { + "title": "手動下單", + "buttonUp": "買入 Up", + "buttonDown": "買入 Down", + "confirmTitle": "手動下單確認", + "marketTitle": "市場標題", + "direction": "方向", + "directionUp": "Up", + "directionDown": "Down", + "orderPrice": "下單價格", + "orderSize": "下單數量", + "totalAmount": "總金額", + "account": "賬戶", + "cancel": "取消", + "confirm": "確認下單", + "orderUnit": "USDC", + "sizeUnit": "張", + "statusNotOrdered": "未下單", + "statusOrdered": "本週期已下單", + "statusOrderedAuto": "本週期已自動下單", + "statusOrderedManual": "本週期已手動下單", + "errorInsufficientBalance": "賬戶餘額不足,可用餘額:{balance} USDC", + "errorPriceOutOfRange": "價格必須在 0~1 之間", + "errorMinSize": "數量不能少於 1 張", + "errorExceedsBalance": "總金額超過可用餘額", + "errorPriceRequired": "請輸入下單價格", + "errorSizeRequired": "請輸入下單數量", + "success": "手動下單成功", + "failed": "手動下單失敗:{{reason}}", + "errorSigning": "簽名失敗:{reason}", + "errorNetwork": "網絡錯誤,請稍後重試", + "priceNotLoaded": "價格數據未加載" } } } \ No newline at end of file diff --git a/frontend/src/pages/CryptoTailMonitor.tsx b/frontend/src/pages/CryptoTailMonitor.tsx index ee47bda..c703fc1 100644 --- a/frontend/src/pages/CryptoTailMonitor.tsx +++ b/frontend/src/pages/CryptoTailMonitor.tsx @@ -12,9 +12,12 @@ import { Alert, Radio, Button, - Tooltip + Tooltip, + Modal, + InputNumber, + message } from 'antd' -import { ClockCircleOutlined, SyncOutlined, InfoCircleOutlined } from '@ant-design/icons' +import { ClockCircleOutlined, SyncOutlined, InfoCircleOutlined, ShoppingCartOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import { useMediaQuery } from 'react-responsive' import * as echarts from 'echarts' @@ -84,6 +87,24 @@ const CryptoTailMonitor: React.FC = () => { // 标记当前是否在查看旧周期(手动模式下) const [isViewingOldPeriod, setIsViewingOldPeriod] = useState(false) + // 手动下单状态 + const [manualOrderModal, setManualOrderModal] = useState<{ + visible: boolean + direction: 'UP' | 'DOWN' + price: string + size: string + totalAmount: string + bestBid: string + }>({ + visible: false, + direction: 'UP', + price: '', + size: '', + totalAmount: '', + bestBid: '' + }) + const [ordering, setOrdering] = useState(false) + // 获取策略列表 useEffect(() => { const fetchStrategies = async () => { @@ -709,6 +730,94 @@ const CryptoTailMonitor: React.FC = () => { const spreadBelowThreshold = currentSpread != null && currentSpread !== '' && minSpreadLineNum.length > 0 && parseFloat(currentSpread) < Math.min(...minSpreadLineNum) + // 手动下单:打开弹窗 + const handleOpenManualOrderModal = (direction: 'UP' | 'DOWN') => { + if (!pushData) { + message.warning(t('cryptoTailMonitor.manualOrder.priceNotLoaded')) + return + } + const bestBid = direction === 'UP' ? pushData.currentPriceUp : pushData.currentPriceDown + if (!bestBid) { + message.warning(t('cryptoTailMonitor.manualOrder.priceNotLoaded')) + return + } + // 计算默认价格:最优 bid × 1.1,限制在 0~1 之间 + const rawPrice = parseFloat(bestBid) * 1.1 + const defaultPrice = Math.min(1, Math.max(0, rawPrice)) + const defaultAmountUsdc = 10 + const defaultSize = Math.ceil(defaultAmountUsdc / defaultPrice) + setManualOrderModal({ + visible: true, + direction, + price: defaultPrice.toFixed(4), + size: defaultSize.toFixed(2), + totalAmount: (defaultPrice * defaultSize).toFixed(2), + bestBid + }) + } + + const handleCloseManualOrderModal = () => { + setManualOrderModal({ + visible: false, + direction: 'UP', + price: '', + size: '', + totalAmount: '', + bestBid: '' + }) + } + + const handlePriceChange = (value: number | null) => { + if (value === null) return + const clamped = Math.min(1, Math.max(0, value)) + const price = clamped.toFixed(4) + const size = parseFloat(manualOrderModal.size) + const totalAmount = (clamped * size).toFixed(2) + setManualOrderModal({ ...manualOrderModal, price, totalAmount }) + } + + const handleSizeChange = (value: number | null) => { + if (value === null) return + const size = value.toFixed(2) + const priceRaw = parseFloat(manualOrderModal.price) + const price = Math.min(1, Math.max(0, priceRaw)) + const totalAmount = (price * value).toFixed(2) + setManualOrderModal({ ...manualOrderModal, size, totalAmount, price: price.toFixed(4) }) + } + + const handleManualOrder = async () => { + if (!initData || !pushData) return + try { + setOrdering(true) + const tokenIds: string[] = [] + if (initData.tokenIdUp) tokenIds.push(initData.tokenIdUp) + if (initData.tokenIdDown) tokenIds.push(initData.tokenIdDown) + const request = { + strategyId: initData.strategyId, + periodStartUnix: pushData.periodStartUnix, + direction: manualOrderModal.direction, + price: Math.min(1, Math.max(0, parseFloat(manualOrderModal.price) || 0)).toFixed(4), + size: manualOrderModal.size, + marketTitle: pushData.marketTitle || initData.marketTitle, + tokenIds + } + const res = await apiService.cryptoTailStrategy.manualOrder(request) + if (res.data.code === 0 && res.data.data?.success) { + message.success(t('cryptoTailMonitor.manualOrder.success')) + handleCloseManualOrderModal() + } else { + const reason = res.data.msg?.trim() || 'unknown' + message.error(t('cryptoTailMonitor.manualOrder.failed', { reason })) + } + } catch (error: unknown) { + const err = error as { response?: { data?: { msg?: string } }; message?: string } + const reason = err?.response?.data?.msg?.trim() ?? err?.message?.trim() ?? 'unknown' + message.error(t('cryptoTailMonitor.manualOrder.failed', { reason })) + } finally { + setOrdering(false) + } + } + return (
@@ -914,29 +1023,61 @@ const CryptoTailMonitor: React.FC = () => { /> </Card> + {/* 手动下单 */} + <Card title={t('cryptoTailMonitor.manualOrder.title')} style={{ marginTop: 16 }}> + <Row gutter={16}> + <Col span={12}> + <Button + type="primary" + icon={<ShoppingCartOutlined />} + disabled={!pushData || pushData.triggered || pushData.periodEnded} + onClick={() => handleOpenManualOrderModal('UP')} + loading={ordering} + block + style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }} + > + {t('cryptoTailMonitor.manualOrder.buttonUp')} + </Button> + </Col> + <Col span={12}> + <Button + type="primary" + icon={<ShoppingCartOutlined />} + disabled={!pushData || pushData.triggered || pushData.periodEnded} + onClick={() => handleOpenManualOrderModal('DOWN')} + loading={ordering} + block + style={{ backgroundColor: '#fa8c16', borderColor: '#fa8c16' }} + > + {t('cryptoTailMonitor.manualOrder.buttonDown')} + </Button> + </Col> + </Row> + </Card> + {/* 策略信息 */} <Card title={t('cryptoTailMonitor.strategyInfo.title')} style={{ marginTop: 16 }}> - <Row gutter={[16, 8]}> - <Col span={12}> + <Row gutter={[16, isMobile ? 12 : 8]}> + <Col xs={24} sm={24} md={12}> <Text type="secondary">{t('cryptoTailMonitor.strategyInfo.market')}: </Text> <Text>{pushData?.marketTitle ?? initData.marketTitle}</Text> </Col> - <Col span={12}> + <Col xs={24} sm={24} md={12}> <Text type="secondary">{t('cryptoTailMonitor.strategyInfo.interval')}: </Text> <Text>{initData.intervalSeconds === 300 ? '5m' : '15m'}</Text> </Col> - <Col span={12}> + <Col xs={24} sm={24} md={12}> <Text type="secondary">{t('cryptoTailMonitor.strategyInfo.account')}: </Text> <Text>{initData.accountName || `#${initData.accountId}`}</Text> </Col> - <Col span={12}> + <Col xs={24} sm={24} md={12}> <Text type="secondary">{t('cryptoTailMonitor.strategyInfo.spreadMode')}: </Text> <Text>{initData.minSpreadMode}</Text> {initData.minSpreadMode === 'FIXED' && initData.minSpreadValue && ( <Text> ({formatNumber(initData.minSpreadValue, 4)})</Text> )} </Col> - <Col span={12}> + <Col xs={24} sm={24} md={12}> <Text type="secondary">{t('cryptoTailMonitor.strategyInfo.spreadDirection')}: </Text> <Text>{(initData.spreadDirection ?? 'MIN') === 'MAX' ? t('cryptoTailMonitor.stat.configuredSpreadMax') : t('cryptoTailMonitor.stat.configuredSpreadMin')}</Text> </Col> @@ -944,6 +1085,103 @@ const CryptoTailMonitor: React.FC = () => { </Card> </> )} + + {/* 手动下单确认弹窗 */} + <Modal + title={t('cryptoTailMonitor.manualOrder.confirmTitle')} + open={manualOrderModal.visible} + onCancel={handleCloseManualOrderModal} + footer={[ + <Button key="cancel" onClick={handleCloseManualOrderModal}> + {t('cryptoTailMonitor.manualOrder.cancel')} + </Button>, + <Button + key="confirm" + type="primary" + onClick={handleManualOrder} + loading={ordering} + style={ + manualOrderModal.direction === 'UP' + ? { backgroundColor: '#1890ff', borderColor: '#1890ff' } + : { backgroundColor: '#fa8c16', borderColor: '#fa8c16' } + } + > + {t('cryptoTailMonitor.manualOrder.confirm')} + </Button> + ]} + width={isMobile ? '100%' : 480} + style={isMobile ? { maxWidth: '100%', top: 0, paddingBottom: 0 } : undefined} + > + {initData && ( + <Space direction="vertical" style={{ width: '100%' }} size={16}> + <Row gutter={[12, 8]}> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.marketTitle')} + </Text> + <Text>{pushData?.marketTitle ?? initData.marketTitle}</Text> + </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.direction')} + </Text> + <Text + strong + style={{ + color: manualOrderModal.direction === 'UP' ? '#1890ff' : '#fa8c16' + }} + > + {manualOrderModal.direction === 'UP' + ? t('cryptoTailMonitor.manualOrder.directionUp') + : t('cryptoTailMonitor.manualOrder.directionDown')} + </Text> + </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.orderPrice')} ({t('cryptoTailMonitor.manualOrder.orderUnit')}) + </Text> + <InputNumber + style={{ width: '100%' }} + value={manualOrderModal.price ? parseFloat(manualOrderModal.price) : undefined} + onChange={handlePriceChange} + min={0} + max={1} + step={0.0001} + precision={4} + placeholder="0.0000" + /> + </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.orderSize')} ({t('cryptoTailMonitor.manualOrder.sizeUnit')}) + </Text> + <InputNumber + style={{ width: '100%' }} + value={manualOrderModal.size ? parseFloat(manualOrderModal.size) : undefined} + onChange={handleSizeChange} + min={1} + precision={2} + placeholder="1" + /> + </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.totalAmount')} + </Text> + <Text strong style={{ fontSize: 16 }}> + {manualOrderModal.totalAmount} {t('cryptoTailMonitor.manualOrder.orderUnit')} + </Text> + </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.account')} + </Text> + <Text>{initData.accountName || `#${initData.accountId}`}</Text> + </Col> + </Row> + </Space> + )} + </Modal> </div> ) } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4c8c71a..ac67fa0 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -499,9 +499,19 @@ export const apiService = { autoMinSpread: (data: { intervalSeconds: number }) => apiClient.post<ApiResponse<import('../types').CryptoTailAutoMinSpreadResponse>>('/crypto-tail-strategy/auto-min-spread', data), monitorInit: (strategyId: number) => - apiClient.post<ApiResponse<import('../types').CryptoTailMonitorInitResponse>>('/crypto-tail-strategy/monitor/init', { strategyId }) + apiClient.post<ApiResponse<import('../types').CryptoTailMonitorInitResponse>>('/crypto-tail-strategy/monitor/init', { strategyId }), + manualOrder: (data: { + strategyId: number + periodStartUnix: number + direction: 'UP' | 'DOWN' + price: string + size: string + marketTitle: string + tokenIds: string[] + }) => + apiClient.post<ApiResponse<import('../types').CryptoTailManualOrderResponse>>('/crypto-tail-strategy/manual-order', data) }, - + /** * 订单管理 API */ diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7e1189b..edbd531 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1156,6 +1156,10 @@ export interface CryptoTailMonitorInitResponse { currentTimestamp: number /** 是否启用 */ enabled: boolean + /** 投入金额模式: FIXED or RATIO */ + amountMode?: string + /** 投入金额数值 */ + amountValue?: string } /** @@ -1203,3 +1207,27 @@ export interface CryptoTailMonitorPushData { /** 周期是否已结束 */ periodEnded: boolean } + +export interface CryptoTailManualOrderResponse { + /** 是否成功 */ + success: boolean + /** 订单ID */ + orderId?: string + /** 提示消息 */ + message: string + /** 下单详情 */ + orderDetails?: ManualOrderDetails +} + +export interface ManualOrderDetails { + /** 策略ID */ + strategyId: number + /** 方向 */ + direction: string + /** 下单价格 */ + price: string + /** 下单数量 */ + size: string + /** 总金额 */ + totalAmount: string +}