feat(crypto-tail): 价差策略监控页手动下单与 UI 优化

- 后端:手动下单 API,trigger_type 区分 AUTO/MANUAL,价格 4 位小数向上取整
- 前端:监控页手动买入 Up/Down 按钮、二次确认弹窗、策略信息移动端适配
- 下单价格最多 4 位小数;确认页方向仅显示 Up/Down;按钮文案改为「买入 Up/Down」

Made-with: Cursor
This commit is contained in:
WrBug
2026-02-26 19:23:00 +08:00
parent 708d6ddb41
commit a2be5b7f52
14 changed files with 1045 additions and 197 deletions
@@ -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<ApiResponse<CryptoTailManualOrderResponse>> {
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))
}
}
}
}
@@ -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<String> = emptyList()
)
@@ -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 = ""
)
@@ -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(),
@@ -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<CryptoTailManualOrderResponse> {
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<String> {
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)在内存中保留
@@ -0,0 +1,5 @@
-- 添加触发类型字段到加密价差策略触发记录表
-- AUTO: 自动下单触发
-- MANUAL: 手动下单触发
ALTER TABLE crypto_tail_strategy_trigger
ADD COLUMN trigger_type VARCHAR(20) DEFAULT 'AUTO' COMMENT '触发类型:AUTO(自动)或 MANUAL(手动)';
+206 -1
View File
@@ -1,4 +1,209 @@
# PolyHermes 一键部署脚本使用说明
# PolyHermes One-Click Deployment Script / PolyHermes 一键部署脚本使用说明
[English](#english) | [中文](#中文)
---
<a name="english"></a>
## 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)
---
<a name="中文"></a>
## 中文
## ✨ 核心特性
+175 -179
View File
@@ -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 <<EOF
# ========================================
# PolyHermes 生产环境配置
# 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
# PolyHermes Production Config / 生产环境配置
# Generated / 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
# ========================================
# ============================================
# 基础配置
# Basic / 基础配置
# ============================================
TZ=${TZ}
SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE}
@@ -264,112 +273,107 @@ SERVER_PORT=${SERVER_PORT}
MYSQL_PORT=${MYSQL_PORT}
# ============================================
# 数据库配置
# Database / 数据库配置
# ============================================
DB_URL=jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true
DB_USERNAME=${DB_USERNAME}
DB_PASSWORD=${DB_PASSWORD}
# ============================================
# 安全配置(请妥善保管)
# Security (keep safe) / 安全配置(请妥善保管)
# ============================================
JWT_SECRET=${JWT_SECRET}
ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY}
CRYPTO_SECRET_KEY=${CRYPTO_SECRET_KEY}
# ============================================
# 日志配置
# Logging / 日志配置
# ============================================
LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT}
LOG_LEVEL_APP=${LOG_LEVEL_APP}
# ============================================
# 其他配置
# Other / 其他配置
# ============================================
ALLOW_PRERELEASE=${ALLOW_PRERELEASE}
GITHUB_REPO=${GITHUB_REPO}
EOF
info "配置文件已生成: .env"
info "$(bilingual "配置文件已生成" "Config file generated"): .env"
echo ""
# 显示配置摘要
title "【配置摘要】"
echo " 服务器端口: ${SERVER_PORT}"
echo " MySQL 端口: ${MYSQL_PORT}"
echo " 时区: ${TZ}"
echo " 数据库用户: ${DB_USERNAME}"
echo " 数据库密码: ${DB_PASSWORD:0:8}... (已隐藏)"
echo " JWT 密钥: ${JWT_SECRET:0:16}... (已隐藏)"
echo " 管理员重置密钥: ${ADMIN_RESET_PASSWORD_KEY:0:16}... (已隐藏)"
echo " 加密密钥: ${CRYPTO_SECRET_KEY:0:16}... (已隐藏)"
echo " 日志级别: Root=${LOG_LEVEL_ROOT}, App=${LOG_LEVEL_APP}"
title "$(bilingual "【配置摘要】" "【Config Summary】")"
echo " $(bilingual "服务器端口" "Server port"): ${SERVER_PORT}"
echo " $(bilingual "MySQL 端口" "MySQL port"): ${MYSQL_PORT}"
echo " $(bilingual "时区" "Timezone"): ${TZ}"
echo " $(bilingual "数据库用户" "DB user"): ${DB_USERNAME}"
echo " $(bilingual "数据库密码" "DB password"): ${DB_PASSWORD:0:8}... $(bilingual "(已隐藏)" "(hidden)")"
echo " $(bilingual "JWT 密钥" "JWT secret"): ${JWT_SECRET:0:16}... $(bilingual "(已隐藏)" "(hidden)")"
echo " $(bilingual "管理员重置密钥" "Admin reset key"): ${ADMIN_RESET_PASSWORD_KEY:0:16}... $(bilingual "(已隐藏)" "(hidden)")"
echo " $(bilingual "加密密钥" "Encryption key"): ${CRYPTO_SECRET_KEY:0:16}... $(bilingual "(已隐藏)" "(hidden)")"
echo " $(bilingual "日志级别" "Log level"): Root=${LOG_LEVEL_ROOT}, App=${LOG_LEVEL_APP}"
echo ""
}
# 拉取镜像
# 拉取镜像 / Pull images
pull_images() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 5: 拉取 Docker 镜像"
title " $(bilingual "步骤 5: 拉取 Docker 镜像" "Step 5: Pull Docker Images")"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
info "正在从 Docker Hub 拉取最新镜像..."
info "$(bilingual "正在从 Docker Hub 拉取最新镜像..." "Pulling latest images from Docker Hub...")"
# 拉取应用镜像
if docker pull wrbug/polyhermes:latest; then
info "应用镜像拉取成功: wrbug/polyhermes:latest"
info "$(bilingual "应用镜像拉取成功" "App image pulled"): wrbug/polyhermes:latest"
else
error "应用镜像拉取失败"
warn "可能的原因:"
warn " 1. 网络连接问题"
warn " 2. Docker Hub 服务异常"
warn " 3. 镜像不存在"
error "$(bilingual "应用镜像拉取失败" "Failed to pull app image")"
warn "$(bilingual "可能的原因:" "Possible reasons:")"
warn " 1. $(bilingual "网络连接问题" "Network issue")"
warn " 2. $(bilingual "Docker Hub 服务异常" "Docker Hub unavailable")"
warn " 3. $(bilingual "镜像不存在" "Image not found")"
exit 1
fi
# 拉取 MySQL 镜像
if docker pull mysql:8.2; then
info "MySQL 镜像拉取成功: mysql:8.2"
info "$(bilingual "MySQL 镜像拉取成功" "MySQL image pulled"): mysql:8.2"
else
warn "MySQL 镜像拉取失败,将在启动时自动下载"
warn "$(bilingual "MySQL 镜像拉取失败,将在启动时自动下载" "MySQL pull failed, will download on start")"
fi
echo ""
}
# 部署服务
# 部署服务 / Deploy services
deploy_services() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 6: 部署服务"
title " $(bilingual "步骤 6: 部署服务" "Step 6: Deploy Services")"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# 停止现有服务
if docker compose -f docker-compose.prod.yml ps -q 2>/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 "$@"
+33
View File
@@ -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"
}
}
}
+33
View File
@@ -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": "价格数据未加载"
}
}
}
+33
View File
@@ -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": "價格數據未加載"
}
}
}
+246 -8
View File
@@ -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<boolean>(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 (
<div style={{ padding: isMobile ? 12 : 24 }}>
<Title level={2} style={{ marginBottom: 16, fontSize: isMobile ? 20 : 24 }}>
@@ -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>
)
}
+12 -2
View File
@@ -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
*/
+28
View File
@@ -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
}