Compare commits
293 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83a415fb7e | |||
| 727fa7ed58 | |||
| 8ab8ae5205 | |||
| 0769022878 | |||
| 06a122dd34 | |||
| fb6b469e9f | |||
| 22a6544373 | |||
| 04b7505094 | |||
| 2056417749 | |||
| 748c871af4 | |||
| 53f1381c3b | |||
| 4a3ebb674e | |||
| 2b570a432a | |||
| 5412a0eb49 | |||
| 7b4e702da9 | |||
| e32697e7ee | |||
| d8a75fc8dd | |||
| 6cb48bb6cc | |||
| e78785de7c | |||
| 915d4570df | |||
| 48d6e82f43 | |||
| 46c32df421 | |||
| 686d14b6e5 | |||
| 561ebf0ce3 | |||
| b8e10f340c | |||
| 9c70dc762f | |||
| 8f0a49493c | |||
| e7af4d4821 | |||
| 83bc209489 | |||
| 9b6cc63158 | |||
| 96d224a5a3 | |||
| 97249db546 | |||
| 46e10ebdd8 | |||
| 4ebfacfc21 | |||
| f86749e85d | |||
| 3cc37dac55 | |||
| 5483a1aa77 | |||
| e5acad5091 | |||
| 1688fa9633 | |||
| 1967d97c31 | |||
| 79b154515d | |||
| 9b4d8fc001 | |||
| cb1f43871a | |||
| a2be5b7f52 | |||
| 708d6ddb41 | |||
| 0fb015f6d3 | |||
| c5d59dfdf9 | |||
| 4b277eaeab | |||
| f35bad78d4 | |||
| 0740abcf16 | |||
| 377da4fff6 | |||
| 84c79d8812 | |||
| a0c2d7995b | |||
| d3196a783f | |||
| 4c989a48c4 | |||
| 7c1f8df590 | |||
| 5bb46ebb97 | |||
| cbcebf6e28 | |||
| 2013a2eb70 | |||
| de89175c8e | |||
| 4e1bb0bbcf | |||
| 24a4487d0b | |||
| 24bb7bed40 | |||
| 6b56f62532 | |||
| c38529546f | |||
| 08e1219f77 | |||
| 62842f5aa8 | |||
| 13662dd0d5 | |||
| 07c3548401 | |||
| 0f50939625 | |||
| a64d1edbf4 | |||
| a62f1bd6e8 | |||
| 63ce4107c6 | |||
| a136d68e8c | |||
| 61a5077d4d | |||
| bd529d9041 | |||
| 5cdcc487d4 | |||
| ca2b1acbb9 | |||
| 77b681a40e | |||
| c6f9e5db61 | |||
| 2ec4ae3f98 | |||
| 5074bb4f28 | |||
| 9fef4bea59 | |||
| b50e43c239 | |||
| 7ec9311df2 | |||
| 1f6cf1ecaf | |||
| 9413507997 | |||
| bf0e52fc01 | |||
| c520eb7777 | |||
| d6d4b1200e | |||
| a1f3cfd3ff | |||
| 261dbdc23c | |||
| 4c0d0afceb | |||
| b79caceae6 | |||
| ed749fb606 | |||
| f200ba7f7c | |||
| 0efd4a5fc3 | |||
| 2238370088 | |||
| 364db124d1 | |||
| f1ec0a330b | |||
| 72de65d670 | |||
| 3405a1cda3 | |||
| fc6fa8b419 | |||
| cf2c8a611c | |||
| bd323fca35 | |||
| a067a20a02 | |||
| 99c4328830 | |||
| 1df438d18d | |||
| 366d3d1b25 | |||
| 17a6a4f314 | |||
| 8600449f69 | |||
| 3d47bd50d9 | |||
| 20df471683 | |||
| 5487c7c862 | |||
| c1829fe21b | |||
| b1ad6f02d2 | |||
| 01a1836659 | |||
| b616e10ceb | |||
| d07c4c2c5e | |||
| 7485e2ce24 | |||
| 8f52b5016a | |||
| 5c0808c2cb | |||
| 9d01c120e5 | |||
| ec8cfeac77 | |||
| 7c833f1e9b | |||
| 08cc30a90f | |||
| 8288cd7579 | |||
| 55da551971 | |||
| ff3b24b50e | |||
| 88cae4018a | |||
| 96fbc3f720 | |||
| fd25821e39 | |||
| cdd02e9f3d | |||
| fabbd81f22 | |||
| ace32b37cf | |||
| f1eae8a40b | |||
| ca655f351d | |||
| 40081c2464 | |||
| f1f809f54b | |||
| e8fd1b503b | |||
| 390b3ee876 | |||
| 80976609c7 | |||
| 17eea0183f | |||
| 6980781f89 | |||
| 3350039f05 | |||
| 0bdc0c74d1 | |||
| cccc829cef | |||
| fa1a915e9c | |||
| e5992b5145 | |||
| 42472f6b03 | |||
| c56e08e681 | |||
| 7e87965418 | |||
| e115d459f7 | |||
| dd39e59304 | |||
| 2efc04a3c3 | |||
| 2b20f4b2e2 | |||
| 419c68c024 | |||
| 8889803dd7 | |||
| 9c5517768f | |||
| 4aa85a9c2d | |||
| a77b3b10ee | |||
| 532f4c3e25 | |||
| 04629e73b6 | |||
| 696193c571 | |||
| 38e256c4fb | |||
| 64a78406ed | |||
| 59297ec52f | |||
| 0ff6832dc1 | |||
| 56a8928631 | |||
| 266c6d595d | |||
| b2e0816968 | |||
| dc47583565 | |||
| 26dd3bb387 | |||
| fabfe601c6 | |||
| 662aa47de6 | |||
| c3d9d10d5d | |||
| 9926533049 | |||
| 4d72017b97 | |||
| 45734c051e | |||
| db8471bb16 | |||
| 0dc6f5894f | |||
| c9769aa17a | |||
| 07b4d654b4 | |||
| b65827038f | |||
| d768da72c6 | |||
| 7385efff1a | |||
| 3e2e97e572 | |||
| ae68a33c1e | |||
| deea59fdbf | |||
| b90f86b081 | |||
| f6f5866118 | |||
| b1e69135b8 | |||
| 0c7f34a28a | |||
| c53fcde5d7 | |||
| 81a620af12 | |||
| 5f44a0ca20 | |||
| 5d2cf945f3 | |||
| 227a38fa89 | |||
| abcc004606 | |||
| 64391503c2 | |||
| 92b75d1926 | |||
| e072d0c894 | |||
| cc40493ec6 | |||
| 3a78a84610 | |||
| 692cbd9a80 | |||
| 8f01588221 | |||
| f6fc836e79 | |||
| 9c303e0a82 | |||
| cb8e46919f | |||
| 279806da2b | |||
| 90fa487d1b | |||
| b58bb267a1 | |||
| 6af76c4d80 | |||
| 43de0104e2 | |||
| d624eaeb57 | |||
| 6ad40242b3 | |||
| 2ccab42894 | |||
| 424153cfc7 | |||
| 19508dcd19 | |||
| a16b6fce54 | |||
| 0327eaffe3 | |||
| b13aef944c | |||
| 9ed5190bfe | |||
| 3d5a923caf | |||
| 2af2c0e86a | |||
| d376a82ccc | |||
| 3008cbcb50 | |||
| e75c93ddf4 | |||
| 018697935a | |||
| 53ec252609 | |||
| 6a0fcdaef2 | |||
| 9a00bb19dc | |||
| 3d603cac32 | |||
| f257457ad3 | |||
| 42a318b501 | |||
| e48149a19b | |||
| c0e0942404 | |||
| 6b8c11f171 | |||
| 3e667b70fd | |||
| 2bb8cbc564 | |||
| ecb737ec67 | |||
| 8b73121c5d | |||
| 95930332df | |||
| 185cade11d | |||
| d9443e0d56 | |||
| 0f3baec6cb | |||
| a097376db7 | |||
| 764d684846 | |||
| 6e5ccdfe5c | |||
| 21da06d8b0 | |||
| 5a83444b56 | |||
| 591e678f73 | |||
| 1cfdcb88d3 | |||
| 2cc9cf82ba | |||
| 00f0898d98 | |||
| 1de9b5e958 | |||
| c8e422a94b | |||
| 60e9f9235d | |||
| 46baa416f4 | |||
| ecdb8af14a | |||
| d6027e48eb | |||
| eb45013a93 | |||
| f97bd5b9d9 | |||
| df272156cf | |||
| 502b4fb0b7 | |||
| 64f6a2b897 | |||
| f7f2411b9d | |||
| d96eb3e00a | |||
| cc1a732984 | |||
| ec06003157 | |||
| 3d05b13298 | |||
| fe2db11b75 | |||
| 5c18cbd95d | |||
| ad2fa4eef2 | |||
| 8357546f3c | |||
| a62141ea5e | |||
| 9157203653 | |||
| 23fc20763a | |||
| e96a0b6279 | |||
| 85d8619fe7 | |||
| 89fb980da7 | |||
| 9f0b22fab5 | |||
| 5e544cda07 | |||
| b6d66aa81e | |||
| 62669719d9 | |||
| a6ca6cc3ff | |||
| d1f351108f | |||
| c4ac3a8fe0 | |||
| 1580b60d24 | |||
| 7bc059ec34 | |||
| b5908a9bbf | |||
| c20e0138df | |||
| 20acc72ed6 |
+171
-594
@@ -5,391 +5,62 @@ path: backend/**
|
|||||||
|
|
||||||
# 后端开发规范
|
# 后端开发规范
|
||||||
|
|
||||||
## 代码完成规范
|
## 核心原则
|
||||||
|
- **禁止**在代码中添加 TODO/FIXME/XXX 注释
|
||||||
### TODO 处理规则
|
- **禁止**返回 mock 数据或硬编码的假数据
|
||||||
- **禁止**在代码中添加 TODO 注释
|
- **禁止**在 API 调用失败时返回默认值作为 fallback
|
||||||
- **必须**根据 TODO 的内容直接完成代码实现
|
- 所有功能必须完整实现,确保代码可以正常运行
|
||||||
- 如果遇到暂时无法完全实现的功能,应该:
|
|
||||||
1. 实现一个可用的基础版本
|
|
||||||
2. 添加清晰的注释说明当前实现的限制和后续改进方向
|
|
||||||
3. 确保代码可以正常编译和运行
|
|
||||||
- **禁止**使用 `// TODO: 实现XXX` 这样的注释
|
|
||||||
- **禁止**使用 `// FIXME:` 或 `// XXX:` 这样的注释
|
|
||||||
- 如果某个功能需要依赖外部资源(如 API、库等),应该:
|
|
||||||
1. 先实现一个占位或模拟实现
|
|
||||||
2. 在注释中说明依赖关系和实现方式
|
|
||||||
3. 确保代码逻辑完整,不会因为未实现的功能而崩溃
|
|
||||||
|
|
||||||
### API 调用实现规则
|
|
||||||
- **必须**查找相关的 API 文档或接口定义
|
|
||||||
- **必须**根据 API 文档完成代码实现
|
|
||||||
- **禁止**在 API 调用处添加 TODO 注释
|
|
||||||
- **禁止**直接返回 mock 数据或硬编码的假数据
|
|
||||||
- **必须**调用真实的 API 或查询真实的数据库
|
|
||||||
- 如果 API 文档不完整,应该:
|
|
||||||
1. 查找项目中已有的类似 API 调用作为参考
|
|
||||||
2. 查看 API 接口定义(如 `PolymarketClobApi.kt`)
|
|
||||||
3. 查看 API 文档(如 `docs/polymarket-api-reference.md`)
|
|
||||||
4. 实现一个可用的版本,包含错误处理
|
|
||||||
- 如果 API 调用失败,应该:
|
|
||||||
1. 返回明确的错误信息
|
|
||||||
2. 记录错误日志
|
|
||||||
3. 返回错误响应,而不是返回 mock 数据
|
|
||||||
|
|
||||||
### 代码完成示例
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// ❌ 错误:添加 TODO 注释
|
|
||||||
fun getAccountBalance(accountId: Long?): Result<AccountBalanceResponse> {
|
|
||||||
// TODO: 调用 Polymarket API 查询余额
|
|
||||||
return Result.success(AccountBalanceResponse(usdcBalance = "0"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ 正确:查找 API 文档并完成实现
|
|
||||||
// 1. 查找 API 接口定义:PolymarketClobApi.getActiveOrders()
|
|
||||||
// 2. 查找 API 文档:docs/polymarket-api-reference.md
|
|
||||||
// 3. 实现完整的 API 调用逻辑,调用真实的 API
|
|
||||||
fun getAccountBalance(accountId: Long?): Result<AccountBalanceResponse> {
|
|
||||||
return try {
|
|
||||||
val account = getAccount(accountId) ?: return Result.failure(IllegalArgumentException("账户不存在"))
|
|
||||||
|
|
||||||
// 如果账户没有配置 API 凭证,返回错误而不是 mock 数据
|
|
||||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
|
||||||
return Result.failure(IllegalStateException("账户未配置 API 凭证,无法查询余额"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解密 API 凭证并创建认证客户端
|
|
||||||
val apiKey = cryptoUtils.decrypt(account.apiKey!!)
|
|
||||||
val apiSecret = cryptoUtils.decrypt(account.apiSecret!!)
|
|
||||||
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase!!)
|
|
||||||
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase)
|
|
||||||
|
|
||||||
// 调用真实的 API 查询余额
|
|
||||||
val response = clobApi.getActiveOrders(limit = 100)
|
|
||||||
if (response.isSuccessful && response.body() != null) {
|
|
||||||
// 根据 API 响应处理余额数据(从真实响应中解析)
|
|
||||||
val orders = response.body()!!
|
|
||||||
// 实际应该调用余额查询接口或从链上查询
|
|
||||||
// 这里只是示例,实际应该调用真实的余额查询 API
|
|
||||||
val balance = queryRealBalanceFromApi(clobApi, account.walletAddress)
|
|
||||||
Result.success(AccountBalanceResponse(usdcBalance = balance))
|
|
||||||
} else {
|
|
||||||
logger.error("查询余额失败: ${response.code()} ${response.message()}")
|
|
||||||
Result.failure(Exception("查询余额失败: ${response.code()} ${response.message()}"))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.error("查询账户余额失败", e)
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 需求文档引用
|
|
||||||
|
|
||||||
### 跟单系统需求
|
|
||||||
- **需求文档**: `docs/copy-trading-requirements.md`
|
|
||||||
- **说明**: 所有跟单系统相关的功能实现必须严格按照需求文档执行
|
|
||||||
- **核心功能**:
|
|
||||||
- 账户管理(通过私钥导入,支持多账户)
|
|
||||||
- Leader 管理(被跟单者管理)
|
|
||||||
- 订单同步与执行(监控 Leader 交易并自动复制)
|
|
||||||
- 跟单配置管理(全局配置和单个 Leader 配置)
|
|
||||||
- 风险控制(每日亏损限制、订单数限制等)
|
|
||||||
- 跟单记录与统计
|
|
||||||
|
|
||||||
**重要提示**: 在实现跟单系统相关功能时,请先查阅 `docs/copy-trading-requirements.md` 了解详细需求,包括:
|
|
||||||
- 数据模型设计(Account、Leader、CopyOrder 等)
|
|
||||||
- API 接口设计(请求/响应格式)
|
|
||||||
- 业务规则和验证逻辑
|
|
||||||
- 安全要求(私钥加密存储、API Key 管理等)
|
|
||||||
|
|
||||||
## 项目范围
|
## 项目范围
|
||||||
|
- **平台**: 仅支持 Polymarket
|
||||||
### 平台支持
|
|
||||||
- **仅支持**: Polymarket 平台
|
|
||||||
- **不支持**: 其他预测市场平台(如 Kalshi 等)
|
|
||||||
|
|
||||||
### 分类支持
|
|
||||||
- **仅支持**:
|
|
||||||
- `sports`: 体育相关市场
|
|
||||||
- `crypto`: 加密货币相关市场
|
|
||||||
- **不支持**: 其他分类(如 politics、entertainment 等)
|
|
||||||
|
|
||||||
### 分类验证
|
|
||||||
- 所有涉及分类的接口、实体、服务必须验证分类参数
|
|
||||||
- 分类参数只能是 `sports` 或 `crypto`
|
|
||||||
- 无效分类应返回明确的错误提示
|
|
||||||
|
|
||||||
## 包名规范
|
|
||||||
- **包名**: `com.wrbug.polymarketbot`
|
- **包名**: `com.wrbug.polymarketbot`
|
||||||
- 所有代码必须使用此包名
|
|
||||||
|
|
||||||
## 实体类规范
|
## 实体类规范
|
||||||
|
|
||||||
### ID字段规范
|
|
||||||
- **必须**使用 `Long? = null` 作为 `@GeneratedValue` 的 id 字段
|
|
||||||
- **禁止**使用 `Long = 0` 或其他默认值
|
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// ✅ 正确
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "example_table")
|
@Table(name = "example_table")
|
||||||
data class ExampleEntity(
|
data class ExampleEntity(
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
val id: Long? = null,
|
val id: Long? = null, // ✅ 正确:使用可空类型
|
||||||
// ...
|
|
||||||
)
|
@Column(name = "created_at", nullable = false)
|
||||||
|
val createdAt: Long = System.currentTimeMillis(), // ✅ 使用 Long 时间戳
|
||||||
// ❌ 错误
|
|
||||||
@Entity
|
@Column(name = "amount", nullable = false, precision = 20, scale = 8)
|
||||||
data class ExampleEntity(
|
val amount: BigDecimal = BigDecimal.ZERO // ✅ 使用 BigDecimal
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
val id: Long = 0, // 禁止使用
|
|
||||||
// ...
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**规则**:
|
||||||
|
- ID 字段必须使用 `Long? = null`
|
||||||
|
- 时间字段使用 `Long` 时间戳(毫秒)
|
||||||
|
- 数值字段使用 `BigDecimal`
|
||||||
|
- **禁止**使用 `LocalDateTime` 或 `Double`
|
||||||
|
|
||||||
## 配置文件规范
|
## 配置文件规范
|
||||||
|
- **必须**使用 `application.properties` 格式(禁止 `application.yml`)
|
||||||
### 配置文件格式
|
- 使用 `${ENV_VAR:default}` 引用环境变量
|
||||||
- **必须**使用 `application.properties` 格式
|
|
||||||
- **禁止**使用 `application.yml` 格式
|
|
||||||
- 配置文件位置: `src/main/resources/application.properties`
|
|
||||||
|
|
||||||
### 配置示例
|
|
||||||
```properties
|
|
||||||
# 应用配置
|
|
||||||
spring.application.name=polymarket-bot-backend
|
|
||||||
|
|
||||||
# 数据源配置
|
|
||||||
spring.datasource.url=jdbc:mysql://localhost:3306/polymarket_bot?useSSL=false&serverTimezone=UTC&characterEncoding=utf8mb4
|
|
||||||
spring.datasource.username=${DB_USERNAME:root}
|
|
||||||
spring.datasource.password=${DB_PASSWORD:password}
|
|
||||||
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
|
|
||||||
|
|
||||||
# HikariCP 连接池配置
|
|
||||||
spring.datasource.hikari.maximum-pool-size=10
|
|
||||||
spring.datasource.hikari.minimum-idle=2
|
|
||||||
spring.datasource.hikari.connection-timeout=30000
|
|
||||||
|
|
||||||
# JPA 配置
|
|
||||||
spring.jpa.hibernate.ddl-auto=validate
|
|
||||||
spring.jpa.show-sql=false
|
|
||||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
|
|
||||||
|
|
||||||
# Flyway 配置
|
|
||||||
spring.flyway.enabled=true
|
|
||||||
spring.flyway.locations=classpath:db/migration
|
|
||||||
spring.flyway.baseline-on-migrate=true
|
|
||||||
|
|
||||||
# 服务器配置
|
|
||||||
server.port=${SERVER_PORT:8000}
|
|
||||||
|
|
||||||
# 日志配置
|
|
||||||
logging.level.root=INFO
|
|
||||||
logging.level.com.wrbug.polymarketbot=DEBUG
|
|
||||||
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
|
|
||||||
```
|
|
||||||
|
|
||||||
### 环境变量引用
|
|
||||||
- 使用 `${ENV_VAR:default}` 格式引用环境变量
|
|
||||||
- 支持多环境配置: `application-dev.properties`, `application-prod.properties`
|
|
||||||
|
|
||||||
## 代码规范
|
## 代码规范
|
||||||
|
|
||||||
### Controller规范
|
### Controller
|
||||||
- Controller 方法**禁止**使用 `suspend`
|
- **禁止**使用 `suspend`
|
||||||
- 如需调用 suspend 方法,使用 `runBlocking`(最小化使用)
|
- 使用 `runBlocking` 调用 suspend 方法
|
||||||
- 只作用于 suspend 方法调用
|
- 统一使用 `@PostMapping`
|
||||||
|
|
||||||
```kotlin
|
### Service
|
||||||
// ✅ 正确
|
- 可以使用 `suspend` 方法
|
||||||
@RestController
|
|
||||||
class ExampleController(
|
|
||||||
private val exampleService: ExampleService
|
|
||||||
) {
|
|
||||||
@PostMapping("/example")
|
|
||||||
fun getExample(): ResponseEntity<ApiResponse<ExampleDto>> {
|
|
||||||
val data = runBlocking { exampleService.getData() }
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ❌ 错误
|
|
||||||
@RestController
|
|
||||||
class ExampleController {
|
|
||||||
@PostMapping("/example")
|
|
||||||
suspend fun getExample(): ResponseEntity<ApiResponse<ExampleDto>> { // 禁止使用suspend
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Service规范
|
|
||||||
- Service 层可以使用 `suspend` 方法
|
|
||||||
- 使用 `@Transactional` 管理事务
|
- 使用 `@Transactional` 管理事务
|
||||||
- 使用构造函数注入依赖
|
- 使用构造函数注入依赖
|
||||||
|
|
||||||
```kotlin
|
### Repository
|
||||||
@Service
|
- 继承 `JpaRepository<Entity, Long>`
|
||||||
class ExampleService(
|
|
||||||
private val exampleRepository: ExampleRepository
|
|
||||||
) {
|
|
||||||
suspend fun getAllData(): List<ExampleEntity> {
|
|
||||||
return exampleRepository.findAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun saveData(entity: ExampleEntity): ExampleEntity {
|
|
||||||
return exampleRepository.save(entity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Repository规范
|
|
||||||
- Repository 接口继承 `JpaRepository`
|
|
||||||
- 使用 Spring Data JPA 方法命名规范
|
- 使用 Spring Data JPA 方法命名规范
|
||||||
|
|
||||||
```kotlin
|
## API 接口规范
|
||||||
@Repository
|
|
||||||
interface ExampleRepository : JpaRepository<ExampleEntity, Long> {
|
|
||||||
fun findByCode(code: String): ExampleEntity?
|
|
||||||
fun findByCategoryAndStatus(category: String, status: String): List<ExampleEntity>
|
|
||||||
fun findByCategory(category: String): List<ExampleEntity> // category: sports 或 crypto
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Entity规范
|
### 统一响应格式
|
||||||
- 使用 `@Entity` 和 `@Table` 注解
|
|
||||||
- ID字段使用 `Long? = null`
|
|
||||||
- 时间字段使用 `Long` 时间戳(毫秒)
|
|
||||||
- 数值字段使用 `BigDecimal`,使用 `String` 存储
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@Entity
|
|
||||||
@Table(name = "example_table")
|
|
||||||
data class ExampleEntity(
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
val id: Long? = null,
|
|
||||||
|
|
||||||
@Column(name = "code", unique = true, nullable = false, length = 100)
|
|
||||||
val code: String = "",
|
|
||||||
|
|
||||||
@Column(name = "category", nullable = false, length = 20)
|
|
||||||
val category: String = "", // sports 或 crypto
|
|
||||||
|
|
||||||
@Column(name = "amount", nullable = false, precision = 20, scale = 8)
|
|
||||||
val amount: BigDecimal = BigDecimal.ZERO,
|
|
||||||
|
|
||||||
@Column(name = "status", nullable = false, length = 20)
|
|
||||||
val status: String = "", // active, inactive
|
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
|
||||||
|
|
||||||
@Column(name = "updated_at", nullable = false)
|
|
||||||
var updatedAt: Long = System.currentTimeMillis()
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 数值计算规范
|
|
||||||
|
|
||||||
### BigDecimal使用
|
|
||||||
- 所有数值计算必须使用 `BigDecimal`
|
|
||||||
- 使用 `String` 存储数值
|
|
||||||
- 使用扩展函数进行安全转换和比较
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// 使用扩展函数
|
|
||||||
val amount = "0.5".toSafeBigDecimal()
|
|
||||||
val total = amount.add("0.4".toSafeBigDecimal())
|
|
||||||
|
|
||||||
// 比较
|
|
||||||
if (total.lt(BigDecimal.ONE)) {
|
|
||||||
// 业务逻辑
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 时间字段规范
|
|
||||||
|
|
||||||
### 时间戳使用
|
|
||||||
- 所有时间字段使用 `Long` 类型存储毫秒级时间戳
|
|
||||||
- **禁止**使用 `LocalDateTime` 或其他时间类型
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// ✅ 正确
|
|
||||||
@Column(name = "created_at", nullable = false)
|
|
||||||
val createdAt: Long = System.currentTimeMillis()
|
|
||||||
|
|
||||||
// ❌ 错误
|
|
||||||
@Column(name = "created_at")
|
|
||||||
val createdAt: LocalDateTime = LocalDateTime.now() // 禁止使用
|
|
||||||
```
|
|
||||||
|
|
||||||
## HTTP客户端规范
|
|
||||||
|
|
||||||
### Retrofit使用
|
|
||||||
- 使用 Retrofit 定义 API 接口
|
|
||||||
- 使用 OkHttp 作为底层 HTTP 客户端
|
|
||||||
- 使用拦截器处理认证
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// Polymarket CLOB API接口定义(跟单系统需要)
|
|
||||||
interface PolymarketClobApi {
|
|
||||||
@POST("/orders")
|
|
||||||
suspend fun createOrder(@Body order: CreateOrderRequest): Response<OrderResponse>
|
|
||||||
|
|
||||||
@GET("/orders/active")
|
|
||||||
suspend fun getActiveOrders(
|
|
||||||
@Query("market") market: String?,
|
|
||||||
@Query("limit") limit: Int?,
|
|
||||||
@Query("offset") offset: Int?
|
|
||||||
): Response<List<OrderResponse>>
|
|
||||||
|
|
||||||
@DELETE("/orders/{orderId}")
|
|
||||||
suspend fun cancelOrder(@Path("orderId") orderId: String): Response<CancelOrderResponse>
|
|
||||||
|
|
||||||
@GET("/trades")
|
|
||||||
suspend fun getTrades(
|
|
||||||
@Query("market") market: String?,
|
|
||||||
@Query("user") user: String?,
|
|
||||||
@Query("limit") limit: Int?,
|
|
||||||
@Query("offset") offset: Int?
|
|
||||||
): Response<List<TradeResponse>>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrofit配置
|
|
||||||
@Configuration
|
|
||||||
class RetrofitConfig {
|
|
||||||
@Bean
|
|
||||||
fun polymarketClobApi(): PolymarketClobApi {
|
|
||||||
val okHttpClient = OkHttpClient.Builder()
|
|
||||||
.addInterceptor(AuthInterceptor())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
return Retrofit.Builder()
|
|
||||||
.baseUrl("https://clob.polymarket.com")
|
|
||||||
.client(okHttpClient)
|
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
|
||||||
.build()
|
|
||||||
.create(PolymarketClobApi::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## API接口规范
|
|
||||||
|
|
||||||
### 请求规范
|
|
||||||
- **所有接口统一使用POST方法**,包括查询类接口
|
|
||||||
- 请求头: `Content-Type: application/json`
|
|
||||||
- 请求体: JSON格式
|
|
||||||
|
|
||||||
### 响应规范
|
|
||||||
- **统一响应格式**:
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
@@ -398,77 +69,7 @@ class RetrofitConfig {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- **响应字段说明**:
|
### 错误码范围
|
||||||
- `code`: 响应码,0表示成功,非0表示失败
|
|
||||||
- `data`: 响应数据,可以是任意类型(对象、数组、字符串、数字等)
|
|
||||||
- `msg`: 响应消息,成功时通常为空,失败时包含错误提示
|
|
||||||
|
|
||||||
### 响应示例
|
|
||||||
|
|
||||||
**成功响应**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"id": "123",
|
|
||||||
"name": "example"
|
|
||||||
},
|
|
||||||
"msg": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**失败响应**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 1001,
|
|
||||||
"data": null,
|
|
||||||
"msg": "参数错误:参数不能为空"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Controller实现示例
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/example")
|
|
||||||
class ExampleController(
|
|
||||||
private val exampleService: ExampleService
|
|
||||||
) {
|
|
||||||
private val logger = LoggerFactory.getLogger(ExampleController::class.java)
|
|
||||||
|
|
||||||
@PostMapping("/list")
|
|
||||||
fun getList(@RequestBody request: ExampleListRequest): ResponseEntity<ApiResponse<ExampleListResponse>> {
|
|
||||||
return try {
|
|
||||||
val data = runBlocking { exampleService.getList(request) }
|
|
||||||
val response = ExampleListResponse(
|
|
||||||
list = data,
|
|
||||||
total = data.size.toLong(),
|
|
||||||
page = request.page ?: 1,
|
|
||||||
limit = request.limit ?: 20
|
|
||||||
)
|
|
||||||
ResponseEntity.ok(ApiResponse.success(response))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.error("Failed to get list", e)
|
|
||||||
ResponseEntity.ok(ApiResponse.serverError("获取列表失败:${e.message}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统一响应格式
|
|
||||||
data class ApiResponse<T>(
|
|
||||||
val code: Int,
|
|
||||||
val data: T?,
|
|
||||||
val msg: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun <T> success(data: T): ApiResponse<T> = ApiResponse(0, data, "")
|
|
||||||
fun <T> paramError(msg: String): ApiResponse<T> = ApiResponse(1001, null, msg)
|
|
||||||
fun <T> serverError(msg: String): ApiResponse<T> = ApiResponse(5001, null, msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 错误码规范
|
|
||||||
- `0`: 成功
|
- `0`: 成功
|
||||||
- `1001-1999`: 参数错误
|
- `1001-1999`: 参数错误
|
||||||
- `2001-2999`: 认证/权限错误
|
- `2001-2999`: 认证/权限错误
|
||||||
@@ -476,7 +77,131 @@ data class ApiResponse<T>(
|
|||||||
- `4001-4999`: 业务逻辑错误
|
- `4001-4999`: 业务逻辑错误
|
||||||
- `5001-5999`: 服务器内部错误
|
- `5001-5999`: 服务器内部错误
|
||||||
|
|
||||||
详细错误码定义参见需求文档
|
### Controller 示例
|
||||||
|
```kotlin
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/example")
|
||||||
|
class ExampleController(
|
||||||
|
private val exampleService: ExampleService,
|
||||||
|
private val messageSource: MessageSource
|
||||||
|
) {
|
||||||
|
@PostMapping("/list")
|
||||||
|
fun getList(@RequestBody request: ExampleListRequest): ResponseEntity<ApiResponse<ExampleListResponse>> {
|
||||||
|
return try {
|
||||||
|
val data = runBlocking { exampleService.getList(request) }
|
||||||
|
ResponseEntity.ok(ApiResponse.success(data))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("Failed to get list", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数值计算
|
||||||
|
```kotlin
|
||||||
|
val amount = "0.5".toSafeBigDecimal()
|
||||||
|
val total = amount.add("0.4".toSafeBigDecimal())
|
||||||
|
|
||||||
|
if (total.lt(BigDecimal.ONE)) {
|
||||||
|
// 业务逻辑
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## JSON 解析规范
|
||||||
|
|
||||||
|
### 使用扩展函数(推荐)
|
||||||
|
- **优先**使用扩展函数 `fromJson<T>()` 和 `toJson()`,禁止直接使用 `Gson`
|
||||||
|
- 扩展函数提供了类型安全的解析,使用方便
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ✅ 正确:使用扩展函数(推荐)
|
||||||
|
val json = "{\"name\":\"test\"}"
|
||||||
|
val obj = json.fromJson<MyDataClass>()
|
||||||
|
val jsonStr = obj.toJson()
|
||||||
|
|
||||||
|
// ✅ 也可以:使用 JsonUtils 类(兼容旧代码)
|
||||||
|
val obj = jsonUtils.fromJson<MyDataClass>(json)
|
||||||
|
|
||||||
|
// ❌ 错误:直接使用 Gson
|
||||||
|
val obj = gson.fromJson(json, MyDataClass::class.java)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 扩展函数列表
|
||||||
|
- `String?.fromJson<T>()` - 解析 JSON 字符串为对象(支持泛型)
|
||||||
|
- `JsonElement?.fromJson<T>()` - 解析 JsonElement 为对象(支持泛型)
|
||||||
|
- `Any?.toJson()` - 将对象转换为 JSON 字符串
|
||||||
|
- `String?.parseStringArray()` - 解析 JSON 字符串数组
|
||||||
|
|
||||||
|
### JsonUtils 类
|
||||||
|
- `JsonUtils` 主要用于初始化全局 Gson 实例,供扩展函数使用
|
||||||
|
- 保留 `parseStringArray()` 方法用于兼容旧代码
|
||||||
|
- 不推荐直接使用 `JsonUtils` 的方法,优先使用扩展函数
|
||||||
|
|
||||||
|
### Data Class 规范
|
||||||
|
- **所有 data class 字段必须提供默认值**
|
||||||
|
- 可空字段使用 `? = null`
|
||||||
|
- 非空字段提供合适的默认值(空字符串、空集合、默认数值等)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ✅ 正确:所有字段都有默认值
|
||||||
|
data class MyDto(
|
||||||
|
val name: String = "",
|
||||||
|
val age: Int = 0,
|
||||||
|
val tags: List<String> = emptyList(),
|
||||||
|
val optional: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// ❌ 错误:缺少默认值
|
||||||
|
data class MyDto(
|
||||||
|
val name: String, // 缺少默认值
|
||||||
|
val age: Int = 0
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Side 判断规范
|
||||||
|
**禁止**使用 "YES"/"NO" 字符串判断 side
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ❌ 错误
|
||||||
|
if (side != null && side.uppercase() == "NO") { }
|
||||||
|
|
||||||
|
// ✅ 正确
|
||||||
|
if (outcomeIndex != null && outcomeIndex == 1) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 多语言规范
|
||||||
|
- **禁止**硬编码中文或英文错误消息
|
||||||
|
- **必须**使用 `ErrorCode` 枚举和 `MessageSource`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ❌ 错误
|
||||||
|
return ResponseEntity.ok(ApiResponse.paramError("配置ID不能为空"))
|
||||||
|
|
||||||
|
// ✅ 正确
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, messageSource))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 多语言支持范围
|
||||||
|
1. **API 响应消息**: 使用 `ErrorCode` + `MessageSource`
|
||||||
|
2. **日志消息**: 可使用中文或英文
|
||||||
|
3. **代码注释**: 建议使用中文
|
||||||
|
4. **数据库字段**: 使用英文(snake_case)
|
||||||
|
|
||||||
|
## HTTP 客户端
|
||||||
|
- 使用 Retrofit + OkHttp
|
||||||
|
- 使用拦截器处理认证
|
||||||
|
|
||||||
|
## 跟单系统需求
|
||||||
|
参考文档: `docs/copy-trading-requirements.md`
|
||||||
|
|
||||||
|
核心功能:
|
||||||
|
- 账户管理(私钥导入,多账户)
|
||||||
|
- Leader 管理
|
||||||
|
- 订单同步与执行
|
||||||
|
- 跟单配置管理
|
||||||
|
- 风险控制
|
||||||
|
- 跟单记录与统计
|
||||||
|
|
||||||
## 禁止事项
|
## 禁止事项
|
||||||
|
|
||||||
@@ -484,173 +209,25 @@ data class ApiResponse<T>(
|
|||||||
- ❌ 禁止使用 `!!` 除非有明确原因
|
- ❌ 禁止使用 `!!` 除非有明确原因
|
||||||
- ❌ 禁止忽略异常
|
- ❌ 禁止忽略异常
|
||||||
- ❌ 禁止硬编码配置值
|
- ❌ 禁止硬编码配置值
|
||||||
- ❌ 禁止提交敏感信息到Git
|
- ❌ 禁止提交敏感信息到 Git
|
||||||
- ❌ Controller 方法禁止使用 `suspend`
|
|
||||||
- ❌ 实体类ID禁止使用 `Long = 0`
|
|
||||||
- ❌ **禁止直接返回 mock 数据或硬编码的假数据**
|
|
||||||
- ❌ **禁止在 API 调用失败时返回 mock 数据作为默认值**
|
|
||||||
- ❌ **所有返回的数据必须来自真实的 API 调用或数据库查询**
|
|
||||||
|
|
||||||
### 配置
|
|
||||||
- ❌ 禁止使用 `application.yml`
|
|
||||||
- ❌ 禁止在代码中硬编码配置值
|
|
||||||
|
|
||||||
### 类型
|
### 类型
|
||||||
- ❌ 禁止使用 `Double` 进行数值计算
|
- ❌ 禁止使用 `Double` 进行数值计算
|
||||||
- ❌ 禁止使用 `LocalDateTime` 存储时间
|
- ❌ 禁止使用 `LocalDateTime` 存储时间
|
||||||
- ❌ 禁止实体类ID使用非空默认值
|
- ❌ 禁止实体类 ID 使用非空默认值
|
||||||
|
- ❌ 禁止直接使用 `Gson`(必须使用 `JsonUtils`)
|
||||||
|
- ❌ 禁止 data class 字段缺少默认值
|
||||||
|
|
||||||
### API接口
|
### API 接口
|
||||||
- ❌ 禁止使用GET、PUT、DELETE等方法(统一使用POST)
|
- ❌ 禁止使用 GET/PUT/DELETE(统一使用 POST)
|
||||||
- ❌ 禁止返回不符合统一格式的响应
|
- ❌ 禁止返回不符合统一格式的响应
|
||||||
- ❌ 禁止在响应中直接返回Map类型(使用data class)
|
- ❌ 禁止在响应中直接返回 Map 类型
|
||||||
|
|
||||||
### Side 判断规范
|
### Side 判断
|
||||||
- ❌ **禁止使用 "YES" 或 "NO" 字符串去判断 side**
|
- ❌ 禁止使用 "YES"/"NO" 字符串判断 side
|
||||||
- ✅ **必须使用 `outcomeIndex` 来判断方向**(0 = 第一个 outcome,1 = 第二个 outcome,以此类推)
|
- ✅ 必须使用 `outcomeIndex` 判断
|
||||||
- ✅ 如果必须使用 side 字符串,应该从市场的 outcomes 数组中获取,而不是硬编码 "YES"/"NO"
|
|
||||||
- ✅ 对于二元市场的价格转换,应该通过 `outcomeIndex` 判断是否为第二个 outcome(index = 1),而不是判断 side 是否为 "NO"
|
|
||||||
|
|
||||||
```kotlin
|
### 数据源
|
||||||
// ❌ 错误:使用字符串比较判断 side
|
- ❌ 禁止直接返回 mock 数据
|
||||||
if (side != null && side.uppercase() == "NO") {
|
- ❌ 禁止在 API 调用失败时返回 mock 数据
|
||||||
// 转换价格
|
- ✅ 所有数据必须来自真实 API 或数据库查询
|
||||||
}
|
|
||||||
|
|
||||||
// ❌ 错误:硬编码 "YES"/"NO" 判断
|
|
||||||
when (side.uppercase()) {
|
|
||||||
"YES" -> // ...
|
|
||||||
"NO" -> // ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ 正确:使用 outcomeIndex 判断
|
|
||||||
if (outcomeIndex != null && outcomeIndex == 1) {
|
|
||||||
// 第二个 outcome(在二元市场中通常是 NO),转换价格
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ 正确:从市场 outcomes 获取 side 信息
|
|
||||||
val outcomes = JsonUtils.parseStringArray(market.outcomes)
|
|
||||||
val targetOutcomeIndex = outcomes.indexOfFirst { it.equals(side, ignoreCase = true) }
|
|
||||||
if (targetOutcomeIndex >= 0) {
|
|
||||||
// 使用 targetOutcomeIndex 进行判断
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 多语言使用规范
|
|
||||||
|
|
||||||
### 错误消息和响应文本
|
|
||||||
- **禁止**在代码中硬编码中文或英文错误消息
|
|
||||||
- **必须**使用 `ErrorCode` 枚举定义错误码和消息
|
|
||||||
- **必须**使用 `ApiResponse.error(ErrorCode, messageSource)` 或 `MessageUtils.getMessage()` 获取国际化消息
|
|
||||||
- **禁止**直接使用 `ApiResponse.paramError("硬编码消息")` 或 `ApiResponse.serverError("硬编码消息")`
|
|
||||||
- 错误消息的默认语言使用中文(在 `ErrorCode` 枚举中定义),通过 `MessageSource` 支持多语言
|
|
||||||
|
|
||||||
### 使用 ErrorCode 和 MessageSource
|
|
||||||
项目已经实现了国际化支持,必须使用以下方式:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// ❌ 错误:硬编码错误消息
|
|
||||||
return ResponseEntity.ok(ApiResponse.paramError("配置ID不能为空"))
|
|
||||||
return ResponseEntity.ok(ApiResponse.serverError("获取配置列表失败:${e.message}"))
|
|
||||||
|
|
||||||
// ✅ 正确:使用 ErrorCode 枚举
|
|
||||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, messageSource = messageSource))
|
|
||||||
|
|
||||||
// ✅ 正确:使用 ErrorCode 和自定义消息(如果需要动态消息)
|
|
||||||
return ResponseEntity.ok(ApiResponse.error(
|
|
||||||
ErrorCode.PARAM_ERROR,
|
|
||||||
customMsg = "配置ID不能为空",
|
|
||||||
messageSource = messageSource
|
|
||||||
))
|
|
||||||
|
|
||||||
// ✅ 正确:使用 MessageUtils
|
|
||||||
@Autowired
|
|
||||||
private lateinit var messageUtils: MessageUtils
|
|
||||||
|
|
||||||
return ResponseEntity.ok(ApiResponse.error(
|
|
||||||
ErrorCode.PARAM_EMPTY,
|
|
||||||
messageSource = messageSource
|
|
||||||
))
|
|
||||||
```
|
|
||||||
|
|
||||||
### 添加新的错误码
|
|
||||||
如果需要添加新的错误码,必须在 `ErrorCode` 枚举中定义:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
enum class ErrorCode(
|
|
||||||
val code: Int,
|
|
||||||
val message: String, // 默认消息(中文)
|
|
||||||
val messageKey: String // 国际化消息键
|
|
||||||
) {
|
|
||||||
// 新错误码示例
|
|
||||||
NOTIFICATION_CONFIG_NOT_FOUND(3009, "通知配置不存在", "error.notification_config_not_found"),
|
|
||||||
NOTIFICATION_CONFIG_INVALID(4009, "通知配置无效", "error.notification_config_invalid"),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
然后在语言资源文件中添加对应的翻译:
|
|
||||||
- `src/main/resources/messages_zh_CN.properties`
|
|
||||||
- `src/main/resources/messages_zh_TW.properties`
|
|
||||||
- `src/main/resources/messages_en.properties`
|
|
||||||
|
|
||||||
### 日志消息规范
|
|
||||||
- **日志消息可以使用中文或英文**,便于开发调试
|
|
||||||
- **禁止**在日志中硬编码用户可见的错误消息(应该使用 ErrorCode)
|
|
||||||
- 日志消息应该清晰、简洁,便于排查问题
|
|
||||||
- 日志中的业务数据(如账户名、订单ID等)可以使用原始值
|
|
||||||
|
|
||||||
### 代码注释规范
|
|
||||||
- **代码注释可以使用中文或英文**
|
|
||||||
- **业务逻辑注释**建议使用中文,便于团队理解
|
|
||||||
- **API 文档注释**(KDoc)建议使用中文,但也可以使用英文
|
|
||||||
- **类和方法注释**应该清晰说明功能和参数
|
|
||||||
|
|
||||||
### 数据库字段和配置
|
|
||||||
- **数据库字段名**使用英文(snake_case)
|
|
||||||
- **配置项名称**使用英文(kebab-case 或 dot.notation)
|
|
||||||
- **枚举值**使用英文(UPPER_SNAKE_CASE)
|
|
||||||
- **常量定义**使用英文(UPPER_SNAKE_CASE)
|
|
||||||
|
|
||||||
### 多语言支持策略
|
|
||||||
1. **API 响应消息**:使用 `ErrorCode` 枚举,通过 `MessageSource` 支持多语言
|
|
||||||
2. **错误码**:使用 `ErrorCode` 枚举,包含 `code`、`message`(默认中文)和 `messageKey`(国际化键)
|
|
||||||
3. **日志消息**:可以使用中文或英文,便于开发调试
|
|
||||||
4. **代码注释**:可以使用中文或英文,建议使用中文
|
|
||||||
5. **业务数据**:根据实际需求,可以包含多语言内容(如市场标题、描述等)
|
|
||||||
|
|
||||||
### Controller 中使用多语言示例
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@RestController
|
|
||||||
class NotificationController(
|
|
||||||
private val notificationConfigService: NotificationConfigService,
|
|
||||||
private val messageSource: MessageSource // 注入 MessageSource
|
|
||||||
) {
|
|
||||||
@PostMapping("/configs/list")
|
|
||||||
fun list(@RequestBody request: NotificationConfigListRequest): ResponseEntity<ApiResponse<List<NotificationConfigDto>>> {
|
|
||||||
return try {
|
|
||||||
// ... 业务逻辑
|
|
||||||
ResponseEntity.ok(ApiResponse.success(configs))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.error("获取配置列表失败: ${e.message}", e)
|
|
||||||
// ✅ 正确:使用 ErrorCode 和 MessageSource
|
|
||||||
ResponseEntity.ok(ApiResponse.error(
|
|
||||||
ErrorCode.SERVER_ERROR,
|
|
||||||
messageSource = messageSource
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/configs/detail")
|
|
||||||
fun detail(@RequestBody request: NotificationConfigDetailRequest): ResponseEntity<ApiResponse<NotificationConfigDto>> {
|
|
||||||
if (request.id == null) {
|
|
||||||
// ✅ 正确:使用 ErrorCode
|
|
||||||
return ResponseEntity.ok(ApiResponse.error(
|
|
||||||
ErrorCode.PARAM_EMPTY,
|
|
||||||
messageSource = messageSource
|
|
||||||
))
|
|
||||||
}
|
|
||||||
// ... 业务逻辑
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
+68
-232
@@ -5,107 +5,41 @@ path: frontend/**
|
|||||||
|
|
||||||
# 前端开发规范
|
# 前端开发规范
|
||||||
|
|
||||||
## 代码完成规范
|
## 核心原则
|
||||||
|
- **禁止**在代码中添加 TODO/FIXME/XXX 注释
|
||||||
### TODO 处理规则
|
- **禁止**使用 `any` 类型
|
||||||
- **禁止**在代码中添加 TODO 注释
|
- **禁止**硬编码中文或英文文本
|
||||||
- **必须**根据 TODO 的内容直接完成代码实现
|
- **必须**支持移动端和桌面端
|
||||||
- 如果遇到暂时无法完全实现的功能,应该:
|
|
||||||
1. 实现一个可用的基础版本
|
|
||||||
2. 添加清晰的注释说明当前实现的限制和后续改进方向
|
|
||||||
3. 确保代码可以正常编译和运行
|
|
||||||
- **禁止**使用 `// TODO: 实现XXX` 这样的注释
|
|
||||||
- **禁止**使用 `// FIXME:` 或 `// XXX:` 这样的注释
|
|
||||||
- 如果某个功能需要依赖外部资源(如 API、库等),应该:
|
|
||||||
1. 先实现一个占位或模拟实现
|
|
||||||
2. 在注释中说明依赖关系和实现方式
|
|
||||||
3. 确保代码逻辑完整,不会因为未实现的功能而崩溃
|
|
||||||
|
|
||||||
### API 调用实现规则
|
|
||||||
- **必须**查找相关的 API 文档或接口定义
|
|
||||||
- **必须**根据 API 文档完成代码实现
|
|
||||||
- **禁止**在 API 调用处添加 TODO 注释
|
|
||||||
- 如果 API 文档不完整,应该:
|
|
||||||
1. 查找项目中已有的类似 API 调用作为参考
|
|
||||||
2. 查看 API 服务定义(如 `services/api.ts`)
|
|
||||||
3. 查看 API 文档(如 `docs/copy-trading-requirements.md`)
|
|
||||||
4. 实现一个可用的版本,包含错误处理
|
|
||||||
|
|
||||||
### 代码完成示例
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ 错误:添加 TODO 注释
|
|
||||||
const fetchAccountBalance = async (accountId: number) => {
|
|
||||||
// TODO: 调用 API 查询余额
|
|
||||||
return { balance: '0' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ 正确:查找 API 定义并完成实现
|
|
||||||
// 1. 查找 API 服务定义:services/api.ts 中的 accounts.balance
|
|
||||||
// 2. 查找 API 文档:docs/copy-trading-requirements.md
|
|
||||||
// 3. 实现完整的 API 调用逻辑
|
|
||||||
const fetchAccountBalance = async (accountId: number) => {
|
|
||||||
try {
|
|
||||||
// 根据 API 服务定义调用接口
|
|
||||||
const response = await apiService.accounts.balance({ accountId })
|
|
||||||
|
|
||||||
// 根据 API 响应格式处理数据
|
|
||||||
if (response.data.code === 0 && response.data.data) {
|
|
||||||
return response.data.data
|
|
||||||
} else {
|
|
||||||
// API 调用失败时返回默认值
|
|
||||||
console.warn('查询余额失败,返回默认值:', response.data.msg)
|
|
||||||
return { balance: '0' }
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('查询余额异常:', error)
|
|
||||||
// 异常时返回默认值,确保代码可以正常运行
|
|
||||||
return { balance: '0' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
- **框架**: React + TypeScript
|
- **框架**: React + TypeScript
|
||||||
- **UI库**: Ant Design 或 Material-UI(推荐 Ant Design Mobile 用于移动端)
|
- **UI 库**: Ant Design(移动端使用 Ant Design Mobile)
|
||||||
- **HTTP客户端**: axios
|
- **HTTP 客户端**: axios
|
||||||
- **状态管理**: Zustand 或 Redux
|
- **状态管理**: Zustand
|
||||||
- **响应式设计**: 必须支持移动端和桌面端
|
- **国际化**: react-i18next
|
||||||
|
|
||||||
## 移动端适配要求
|
## 移动端适配
|
||||||
|
|
||||||
### 响应式设计
|
### 响应式断点
|
||||||
- **必须支持移动端和桌面端**
|
|
||||||
- 使用响应式布局(Responsive Design)
|
|
||||||
- 移动端优先(Mobile First)设计原则
|
|
||||||
- 支持触摸操作和手势
|
|
||||||
|
|
||||||
### 断点设置
|
|
||||||
- **移动端**: < 768px
|
- **移动端**: < 768px
|
||||||
- **平板**: 768px - 1024px
|
- **平板**: 768px - 1024px
|
||||||
- **桌面端**: > 1024px
|
- **桌面端**: > 1024px
|
||||||
|
|
||||||
### UI 组件适配
|
### 适配要求
|
||||||
- 使用 Ant Design 的响应式组件
|
- 使用响应式布局(Mobile First)
|
||||||
- 移动端使用 Ant Design Mobile(如果使用 Ant Design)
|
- 导航栏:移动端抽屉菜单,桌面端顶部导航
|
||||||
- 表格使用虚拟滚动或分页(移动端性能优化)
|
- 列表:移动端卡片布局,桌面端表格布局
|
||||||
- 表单使用移动端友好的输入组件
|
- 按钮:移动端不小于 44x44px
|
||||||
|
- 支持触摸操作和手势
|
||||||
### 布局适配
|
|
||||||
- 导航栏:移动端使用抽屉菜单,桌面端使用顶部导航
|
|
||||||
- 列表:移动端使用卡片布局,桌面端使用表格布局
|
|
||||||
- 按钮:移动端按钮尺寸不小于 44x44px(触摸友好)
|
|
||||||
- 间距:移动端使用更大的间距,提高可点击区域
|
|
||||||
|
|
||||||
### 性能优化
|
### 性能优化
|
||||||
- 图片懒加载
|
- 图片懒加载
|
||||||
- 代码分割(Code Splitting)
|
- 代码分割(Code Splitting)
|
||||||
- 移动端减少动画效果
|
- 移动端减少动画效果
|
||||||
- 使用 CSS 媒体查询优化样式
|
|
||||||
|
|
||||||
## 代码规范
|
## 代码规范
|
||||||
|
|
||||||
### 组件规范
|
### 组件
|
||||||
- 使用函数式组件
|
- 使用函数式组件
|
||||||
- 使用 TypeScript 类型定义
|
- 使用 TypeScript 类型定义
|
||||||
- 组件文件使用 PascalCase 命名
|
- 组件文件使用 PascalCase 命名
|
||||||
@@ -122,24 +56,18 @@ export const MarketCard: React.FC<MarketProps> = ({ marketId, platform }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ❌ 错误
|
// ❌ 错误
|
||||||
export const marketCard = (props: any) => { // 禁止使用any
|
export const marketCard = (props: any) => { }
|
||||||
// ...
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### API调用规范
|
### API 调用
|
||||||
- 使用 axios 进行 HTTP 请求
|
- 使用 axios
|
||||||
- 统一错误处理
|
- 统一错误处理
|
||||||
- 使用 TypeScript 定义响应类型
|
- 使用 TypeScript 定义响应类型
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// API服务
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
interface Market {
|
interface Market {
|
||||||
id: string;
|
id: string;
|
||||||
marketId: string;
|
marketId: string;
|
||||||
platform: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,24 +75,16 @@ export const marketService = {
|
|||||||
getMarkets: async (): Promise<Market[]> => {
|
getMarkets: async (): Promise<Market[]> => {
|
||||||
const response = await axios.get<Market[]>('/api/markets');
|
const response = await axios.get<Market[]>('/api/markets');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
|
||||||
|
|
||||||
getMarketById: async (id: string): Promise<Market> => {
|
|
||||||
const response = await axios.get<Market>(`/api/markets/${id}`);
|
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
### 状态管理规范
|
### 状态管理
|
||||||
- 使用 Zustand 或 Redux 管理全局状态
|
- 全局状态使用 Zustand
|
||||||
- 本地状态使用 `useState`
|
- 本地状态使用 `useState`
|
||||||
- 复杂状态使用 `useReducer`
|
- 复杂状态使用 `useReducer`
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Zustand Store示例
|
|
||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
interface MarketStore {
|
interface MarketStore {
|
||||||
markets: Market[];
|
markets: Market[];
|
||||||
setMarkets: (markets: Market[]) => void;
|
setMarkets: (markets: Market[]) => void;
|
||||||
@@ -176,80 +96,64 @@ export const useMarketStore = create<MarketStore>((set) => ({
|
|||||||
}));
|
}));
|
||||||
```
|
```
|
||||||
|
|
||||||
### USDC 金额格式化规范
|
### USDC 金额格式化
|
||||||
- **必须**使用 `formatUSDC` 函数格式化所有 USDC 金额显示
|
**必须**使用 `formatUSDC` 函数(从 `../utils` 导入)
|
||||||
- **禁止**直接使用 `toFixed()` 或 `parseFloat().toFixed()` 格式化 USDC
|
|
||||||
- **禁止**硬编码小数位数格式化 USDC
|
|
||||||
- 所有 USDC 金额显示必须统一使用 `formatUSDC` 函数
|
|
||||||
|
|
||||||
#### formatUSDC 函数说明
|
|
||||||
- **位置**: `src/utils/index.ts`
|
|
||||||
- **功能**: 格式化 USDC 金额,最多显示 4 位小数,自动去除尾随零(截断,不四舍五入)
|
|
||||||
- **参数**: `value: string | number | undefined | null`
|
|
||||||
- **返回值**: 格式化后的字符串,如果值为空或无效则返回 `'-'`
|
|
||||||
|
|
||||||
#### 使用示例
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ✅ 正确:使用 formatUSDC 格式化 USDC 金额
|
|
||||||
import { formatUSDC } from '../utils'
|
import { formatUSDC } from '../utils'
|
||||||
|
|
||||||
const BalanceDisplay: React.FC<{ balance: string }> = ({ balance }) => {
|
// ✅ 正确
|
||||||
return <span>{formatUSDC(balance)} USDC</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ 正确:在表格列中使用
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
title: '余额',
|
|
||||||
dataIndex: 'balance',
|
|
||||||
render: (balance: string) => `${formatUSDC(balance)} USDC`
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
// ✅ 正确:在统计组件中使用
|
|
||||||
<Statistic
|
<Statistic
|
||||||
title="总盈亏"
|
title="总盈亏"
|
||||||
value={formatUSDC(stats?.totalPnl || '0')}
|
value={formatUSDC(stats?.totalPnl || '0')}
|
||||||
suffix="USDC"
|
suffix="USDC"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
// ❌ 错误:直接使用 toFixed
|
// ❌ 错误
|
||||||
const balance = parseFloat(value).toFixed(4) // 禁止
|
const balance = parseFloat(value).toFixed(4)
|
||||||
|
|
||||||
// ❌ 错误:硬编码格式化
|
|
||||||
const balance = `${parseFloat(value).toFixed(2)} USDC` // 禁止
|
|
||||||
|
|
||||||
// ❌ 错误:使用自定义格式化函数
|
|
||||||
const formatBalance = (value: string) => parseFloat(value).toFixed(4) // 禁止
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 格式化规则
|
**格式化规则**:
|
||||||
- **最多显示 4 位小数**:如果金额超过 4 位小数,截断到 4 位(不四舍五入)
|
- 最多显示 4 位小数(截断,不四舍五入)
|
||||||
- **自动去除尾随零**:去除不必要的尾随零和小数点
|
- 自动去除尾随零
|
||||||
- **示例**:
|
- 空值返回 `'-'`
|
||||||
- `formatUSDC(1.23)` => `"1.23"`
|
|
||||||
- `formatUSDC(1.23456)` => `"1.2345"`(截断,不四舍五入)
|
|
||||||
- `formatUSDC(1.2)` => `"1.2"`
|
|
||||||
- `formatUSDC(1)` => `"1"`
|
|
||||||
- `formatUSDC(1.23459)` => `"1.2345"`(截断,不四舍五入)
|
|
||||||
- `formatUSDC(null)` => `"-"`
|
|
||||||
- `formatUSDC(undefined)` => `"-"`
|
|
||||||
|
|
||||||
#### 工具函数统一导出
|
## 多语言规范
|
||||||
- 所有工具函数统一从 `src/utils/index.ts` 导出
|
|
||||||
- **必须**从 `../utils` 导入工具函数,而不是从具体文件导入
|
### 使用方式
|
||||||
- `ethers.ts` 中的函数也会在 `index.ts` 中统一导出
|
**必须**使用 `useTranslation` Hook
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ✅ 正确:从 utils 统一导入
|
// ✅ 正确
|
||||||
import { formatUSDC, getAddressFromPrivateKey, isValidWalletAddress } from '../utils'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
// ❌ 错误:从具体文件导入
|
const MyComponent: React.FC = () => {
|
||||||
import { formatUSDC } from '../utils/index'
|
const { t } = useTranslation()
|
||||||
import { getAddressFromPrivateKey } from '../utils/ethers'
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{t('page.title')}</h1>
|
||||||
|
<Button>{t('common.save')}</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ 错误:硬编码文本
|
||||||
|
<h1>页面标题</h1>
|
||||||
|
<Button>保存</Button>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 语言文件
|
||||||
|
- 位置: `src/locales/{locale}/common.json`
|
||||||
|
- 支持: `zh-CN`、`zh-TW`、`en`
|
||||||
|
- 键名: 点号分隔的层级结构(如 `notificationSettings.title`)
|
||||||
|
|
||||||
|
### 添加新翻译
|
||||||
|
1. 在 `src/locales/zh-CN/common.json` 添加中文
|
||||||
|
2. 在 `src/locales/zh-TW/common.json` 添加繁体
|
||||||
|
3. 在 `src/locales/en/common.json` 添加英文
|
||||||
|
4. 在组件中使用 `t('key')`
|
||||||
|
|
||||||
## 移动端适配示例
|
## 移动端适配示例
|
||||||
|
|
||||||
### 响应式布局
|
### 响应式布局
|
||||||
@@ -267,29 +171,6 @@ const MyComponent: React.FC = () => {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
### 移动端导航
|
|
||||||
```typescript
|
|
||||||
import { Drawer } from 'antd';
|
|
||||||
|
|
||||||
const MobileNav: React.FC = () => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button onClick={() => setOpen(true)}>菜单</Button>
|
|
||||||
<Drawer
|
|
||||||
title="导航"
|
|
||||||
placement="left"
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
open={open}
|
|
||||||
>
|
|
||||||
{/* 导航内容 */}
|
|
||||||
</Drawer>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 响应式表格
|
### 响应式表格
|
||||||
```typescript
|
```typescript
|
||||||
import { Table } from 'antd';
|
import { Table } from 'antd';
|
||||||
@@ -316,63 +197,18 @@ const ResponsiveTable: React.FC = () => {
|
|||||||
### 代码质量
|
### 代码质量
|
||||||
- ❌ 禁止使用 `any` 类型
|
- ❌ 禁止使用 `any` 类型
|
||||||
- ❌ 禁止忽略错误处理
|
- ❌ 禁止忽略错误处理
|
||||||
- ❌ 禁止硬编码API地址
|
- ❌ 禁止硬编码 API 地址
|
||||||
- ❌ 禁止在组件中直接使用 `fetch`
|
- ❌ 禁止在组件中直接使用 `fetch`
|
||||||
- ❌ 禁止忽略移动端适配
|
- ❌ 禁止忽略移动端适配
|
||||||
|
|
||||||
### 类型安全
|
### 类型安全
|
||||||
- ❌ 禁止使用 `any`
|
|
||||||
- ❌ 禁止忽略 TypeScript 类型检查
|
|
||||||
- ❌ 禁止使用 `@ts-ignore` 除非有明确原因
|
- ❌ 禁止使用 `@ts-ignore` 除非有明确原因
|
||||||
|
|
||||||
### 移动端适配
|
### 移动端适配
|
||||||
- ❌ 禁止固定宽度布局
|
- ❌ 禁止固定宽度布局
|
||||||
- ❌ 禁止使用过小的触摸目标(< 44x44px)
|
- ❌ 禁止使用过小的触摸目标(< 44x44px)
|
||||||
- ❌ 禁止忽略移动端性能优化
|
- ❌ 禁止使用桌面端专用交互(如 hover)
|
||||||
- ❌ 禁止使用桌面端专用的交互方式(如 hover)
|
|
||||||
|
|
||||||
### 多语言使用规范
|
### 多语言
|
||||||
- **必须**使用多语言(i18n)进行所有文本显示
|
- ❌ 禁止硬编码中文或英文文本
|
||||||
- **禁止**在组件中硬编码中文或英文文本
|
- ❌ 禁止绕过 `useTranslation` 直接显示文本
|
||||||
- **必须**使用 `useTranslation` Hook 获取翻译函数
|
|
||||||
- **必须**在语言文件中定义所有显示的文本
|
|
||||||
- **必须**支持至少中文(简体/繁体)和英文
|
|
||||||
|
|
||||||
#### 多语言使用示例
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ 正确:使用多语言
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const MyComponent: React.FC = () => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>{t('page.title')}</h1>
|
|
||||||
<Button>{t('common.save')}</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ❌ 错误:硬编码文本
|
|
||||||
const MyComponent: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>页面标题</h1> {/* 禁止硬编码 */}
|
|
||||||
<Button>保存</Button> {/* 禁止硬编码 */}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 语言文件结构
|
|
||||||
- 语言文件位置:`src/locales/{locale}/common.json`
|
|
||||||
- 支持的语言:`zh-CN`(简体中文)、`zh-TW`(繁体中文)、`en`(英文)
|
|
||||||
- 键名使用点号分隔的层级结构,如:`notificationSettings.title`
|
|
||||||
|
|
||||||
#### 添加新翻译的步骤
|
|
||||||
1. 在 `src/locales/zh-CN/common.json` 中添加中文翻译
|
|
||||||
2. 在 `src/locales/zh-TW/common.json` 中添加繁体中文翻译
|
|
||||||
3. 在 `src/locales/en/common.json` 中添加英文翻译
|
|
||||||
4. 在组件中使用 `t('key')` 获取翻译
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Cursor Skills 目录结构
|
||||||
|
|
||||||
|
本目录遵循 [Cursor Agent Skills 规范](https://cursor.com/cn/docs/context/skills):每个技能为一个**文件夹**,内含 `SKILL.md` 及可选的 `scripts/`、`references/`、`assets/`。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
.cursor/skills/
|
||||||
|
├── frontend/ # 前端相关 skill 分组
|
||||||
|
│ └── check-i18n-keys/ # 单个 skill(文件夹名 = name)
|
||||||
|
│ ├── SKILL.md # 必填,技能定义与指令
|
||||||
|
│ └── scripts/
|
||||||
|
│ ├── check-i18n-keys.ts
|
||||||
|
│ └── package.json
|
||||||
|
├── backend/ # 后端相关 skill
|
||||||
|
└── common/ # 通用 skill
|
||||||
|
└── create-release/ # 创建 GitHub Release
|
||||||
|
├── SKILL.md
|
||||||
|
└── scripts/
|
||||||
|
└── create-release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- **SKILL.md**:YAML frontmatter(`name`、`description` 必填,`name` 须与父文件夹名一致、小写连字符)+ 给 Agent 的详细指令。
|
||||||
|
- **scripts/**:Agent 可执行的脚本,在 SKILL.md 中用相对路径引用。
|
||||||
|
|
||||||
|
## 添加新 Skill
|
||||||
|
|
||||||
|
1. **确定分组**:在 `frontend/`、`backend/` 或 `common/` 下新建**以技能名命名的文件夹**(仅小写、数字、连字符,如 `check-i18n-keys`)。
|
||||||
|
2. **创建 SKILL.md**:在该文件夹内创建 `SKILL.md`(大写),frontmatter 中 `name` 必须与文件夹名一致。
|
||||||
|
3. **可选 scripts/**:在技能文件夹内建 `scripts/`,放入可执行脚本;在 SKILL.md 正文中写明运行命令(如 `cd .cursor/skills/.../scripts && npm run xxx`)。
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
- `frontend/check-i18n-keys/SKILL.md` + `frontend/check-i18n-keys/scripts/` — 检查前端多语言 key
|
||||||
|
- `common/create-release/SKILL.md` + `common/create-release/scripts/` — 创建 GitHub Release
|
||||||
|
|
||||||
|
## 运行 check-i18n-keys
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd .cursor/skills/frontend/check-i18n-keys/scripts
|
||||||
|
npm install
|
||||||
|
npm run check-i18n
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行 create-release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd .cursor/skills/common/create-release/scripts
|
||||||
|
./create-release.sh -t v1.0.0 -T "Release v1.0.0" -d "发布说明"
|
||||||
|
```
|
||||||
|
|
||||||
|
参数说明:
|
||||||
|
- `-t` 版本号(必需,格式 v1.0.0)
|
||||||
|
- `-T` Release 标题
|
||||||
|
- `-d` Release 描述
|
||||||
|
- `-p` 标记为 Pre-release(自动加 -beta 后缀)
|
||||||
|
- `-y` 无交互模式
|
||||||
|
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
---
|
||||||
|
name: create-release
|
||||||
|
description: 创建 PolyHermes 项目的 GitHub Release。当用户要求发布版本、创建 release、打 tag 或发布新版本时使用。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Create Release
|
||||||
|
|
||||||
|
创建 PolyHermes 项目的 GitHub Release,包括创建 Git tag、推送 tag、创建 GitHub Release(支持 pre-release),并自动在 Issue #1 发布公告。
|
||||||
|
|
||||||
|
## 使用时机
|
||||||
|
|
||||||
|
- 用户要求「发布版本」「创建 release」「打 tag」「发布新版本」时
|
||||||
|
- 用户要求「创建 pre-release」「beta 版本」时
|
||||||
|
- 用户提到「v1.x.x」等版本号相关操作时
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
1. **GitHub CLI 已安装**:确保 `gh` 命令可用
|
||||||
|
2. **已登录 GitHub**:运行 `gh auth status` 确认
|
||||||
|
3. **工作目录干净**:建议先提交所有更改
|
||||||
|
|
||||||
|
## 指令
|
||||||
|
|
||||||
|
### 步骤 1:收集发布信息
|
||||||
|
|
||||||
|
询问用户以下信息:
|
||||||
|
- 版本号(格式:vX.Y.Z,如 v1.0.0)
|
||||||
|
- 是否为 Pre-release(测试版本)
|
||||||
|
- Release 标题和描述(可选,如未提供则自动生成)
|
||||||
|
|
||||||
|
### 步骤 2:生成 Release 内容
|
||||||
|
|
||||||
|
**重要**:如果用户未提供描述,需要根据 Git commits 自动生成。
|
||||||
|
|
||||||
|
1. **获取上一个版本的 tag**:
|
||||||
|
```bash
|
||||||
|
git describe --tags --abbrev=0 HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **获取版本间的 commits**:
|
||||||
|
```bash
|
||||||
|
git log <PREVIOUS_TAG>..HEAD --oneline --no-merges
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **过滤 commit 规则**:
|
||||||
|
- **排除**:版本内新增功能的修复 commit
|
||||||
|
- **判断方法**:如果一个 commit 的消息包含「fix」「修复」「bugfix」等关键词,且是针对同一版本内新增代码的修复,则不包含
|
||||||
|
- **保留**:新功能、性能优化、重构、文档更新等
|
||||||
|
|
||||||
|
4. **生成中英文 Release 内容**:
|
||||||
|
- 格式要求:**中文在上,英文在下**
|
||||||
|
- 使用分隔线 `---` 分隔中英文部分
|
||||||
|
- 按功能类型分组(新功能、改进、修复等)
|
||||||
|
|
||||||
|
示例格式:
|
||||||
|
```markdown
|
||||||
|
## 新功能
|
||||||
|
|
||||||
|
- 添加了 A 功能
|
||||||
|
- 支持了 B 操作
|
||||||
|
|
||||||
|
## 改进
|
||||||
|
|
||||||
|
- 优化了 C 性能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
- Added feature A
|
||||||
|
- Supported operation B
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
- Optimized performance C
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 3:运行发布脚本
|
||||||
|
|
||||||
|
在项目根目录下执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd .cursor/skills/common/create-release/scripts && \
|
||||||
|
chmod +x create-release.sh && \
|
||||||
|
./create-release.sh -t <VERSION> [-T "<TITLE>"] [-d "<DESCRIPTION>"] [-p] [-y]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 4:发布公告到 Issue #1
|
||||||
|
|
||||||
|
**重要**:Release 创建成功后,必须自动在 Issue #1 下发布公告 comment。
|
||||||
|
|
||||||
|
1. **生成公告内容**(面向用户,通俗易懂):
|
||||||
|
|
||||||
|
公告格式模板:
|
||||||
|
```markdown
|
||||||
|
# 🎉 PolyHermes vX.X.X 版本发布公告
|
||||||
|
|
||||||
|
## 📅 发布日期
|
||||||
|
|
||||||
|
YYYY年MM月DD日
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 本次更新亮点
|
||||||
|
|
||||||
|
### 🚀 新功能
|
||||||
|
|
||||||
|
**功能名称**
|
||||||
|
- 用通俗的语言描述这个功能是什么
|
||||||
|
- 用户能从中获得什么好处
|
||||||
|
- 如何使用这个功能
|
||||||
|
|
||||||
|
### 🔧 改进优化
|
||||||
|
|
||||||
|
- 优化了 XXX,现在 XXX 更快/更稳定了
|
||||||
|
- 改进了 XXX 体验,操作更简单了
|
||||||
|
|
||||||
|
### 🐛 问题修复
|
||||||
|
|
||||||
|
- 修复了 XXX 问题,不再出现 XXX 情况
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 如何更新
|
||||||
|
|
||||||
|
### Docker 部署(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 拉取最新镜像
|
||||||
|
docker pull wrbug/polyhermes:vX.X.X
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
docker-compose -f docker-compose.prod.yml down
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 安全提醒
|
||||||
|
|
||||||
|
**请务必使用官方 Docker 镜像源,避免财产损失!**
|
||||||
|
|
||||||
|
**官方镜像地址**:`wrbug/polyhermes`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关链接
|
||||||
|
|
||||||
|
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/vX.X.X
|
||||||
|
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **公告内容编写原则**:
|
||||||
|
|
||||||
|
- ✅ **通俗易懂**:避免技术术语,用用户能理解的语言
|
||||||
|
- ✅ **突出价值**:告诉用户这个更新对他们有什么好处
|
||||||
|
- ✅ **简洁明了**:每个功能点用 1-2 句话说明
|
||||||
|
- ✅ **包含操作指引**:告诉用户如何使用新功能
|
||||||
|
- ✅ **中英文双语**:中文在上,英文在下(可选)
|
||||||
|
- ❌ **避免**:commit hash、代码细节、内部实现
|
||||||
|
|
||||||
|
3. **执行发布公告命令**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh issue comment 1 --repo WrBug/PolyHermes --body "$(cat <<'EOF'
|
||||||
|
# 🎉 PolyHermes vX.X.X 版本发布公告
|
||||||
|
|
||||||
|
[公告内容...]
|
||||||
|
|
||||||
|
---
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
或使用文件方式(内容较长时推荐):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "[公告内容...]" > /tmp/announcement.md
|
||||||
|
gh issue comment 1 --repo WrBug/PolyHermes --body-file /tmp/announcement.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| `-t, --tag` | 版本号(必需) | `-t v1.0.0` |
|
||||||
|
| `-T, --title` | Release 标题 | `-T "Release v1.0.0"` |
|
||||||
|
| `-d, --description` | Release 描述 | `-d "## 新功能\n- 功能1"` |
|
||||||
|
| `-f, --description-file` | 从文件读取描述 | `-f CHANGELOG.md` |
|
||||||
|
| `-p, --prerelease` | 标记为 Pre-release(自动加 -beta 后缀) | `-p` |
|
||||||
|
| `-y, --yes` | 无交互模式 | `-y` |
|
||||||
|
|
||||||
|
## 版本号格式
|
||||||
|
|
||||||
|
- 必须格式:`v数字.数字.数字`(如 v1.0.0, v1.10.2, v1.1.12)
|
||||||
|
- 如果指定 `--prerelease`,会自动拼接 `-beta` 后缀(如 v1.0.1 → v1.0.1-beta)
|
||||||
|
|
||||||
|
## Release 内容生成规则
|
||||||
|
|
||||||
|
### 中英文格式
|
||||||
|
|
||||||
|
Release 描述**必须**使用中英文双语格式:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 中文标题
|
||||||
|
|
||||||
|
- 内容项1
|
||||||
|
- 内容项2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## English Title
|
||||||
|
|
||||||
|
- Item 1
|
||||||
|
- Item 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commit 过滤规则
|
||||||
|
|
||||||
|
**需要排除的 commit 类型**:
|
||||||
|
|
||||||
|
1. **版本内修复**:对同一版本新增功能的后续修复
|
||||||
|
- 例如:v1.0.1 新增了功能 A,然后有一个 commit 修复功能 A 的 bug → 不包含
|
||||||
|
- 判断依据:commit 消息包含「fix」「修复」「bugfix」且相关功能在本版本新增
|
||||||
|
|
||||||
|
2. **琐碎修改**:
|
||||||
|
- typo 修正
|
||||||
|
- 代码格式调整
|
||||||
|
- 注释更新
|
||||||
|
|
||||||
|
**需要保留的 commit 类型**:
|
||||||
|
|
||||||
|
1. 新功能(feat、feature)
|
||||||
|
2. 改进/优化(improve、optimize、enhance)
|
||||||
|
3. 重要 bug 修复(针对旧版本的 bug)
|
||||||
|
4. 重构(refactor)
|
||||||
|
5. 文档更新(docs)
|
||||||
|
|
||||||
|
### 分组建议
|
||||||
|
|
||||||
|
- **新功能 / New Features**
|
||||||
|
- **改进 / Improvements**
|
||||||
|
- **修复 / Bug Fixes**(仅包含对旧版本 bug 的修复)
|
||||||
|
- **其他 / Others**
|
||||||
|
|
||||||
|
## 公告内容示例
|
||||||
|
|
||||||
|
以下是一个面向用户的公告示例:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 🎉 PolyHermes v1.2.0 版本发布公告
|
||||||
|
|
||||||
|
## 📅 发布日期
|
||||||
|
|
||||||
|
2026年3月2日
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 本次更新亮点
|
||||||
|
|
||||||
|
### 🚀 新功能
|
||||||
|
|
||||||
|
**系统自动更新**
|
||||||
|
- 现在可以在网页上直接更新系统,无需手动重启 Docker
|
||||||
|
- 更新过程约 30-60 秒,系统会自动处理
|
||||||
|
- 如果更新失败,系统会自动恢复到旧版本
|
||||||
|
|
||||||
|
**RPC 节点管理**
|
||||||
|
- 可以在系统设置中添加、编辑、删除自定义 RPC 节点
|
||||||
|
- 可以随时启用或禁用节点
|
||||||
|
- 系统会自动选择可用的节点
|
||||||
|
|
||||||
|
### 🔧 改进优化
|
||||||
|
|
||||||
|
- **更快的跟单响应**:通过实时监听链上交易,跟单速度提升到秒级
|
||||||
|
- **更准确的盈亏统计**:系统会自动追踪实际成交价,统计数据更准确
|
||||||
|
- **内存占用优化**:修复了内存泄漏问题,系统可以长时间稳定运行
|
||||||
|
|
||||||
|
### 🐛 问题修复
|
||||||
|
|
||||||
|
- 修复了部分市场无法正确查询价格的问题
|
||||||
|
- 修复了卖出订单偶发失败的问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 如何更新
|
||||||
|
|
||||||
|
### 方式一:网页更新(推荐)
|
||||||
|
|
||||||
|
1. 登录系统,进入 **系统设置** → **系统更新**
|
||||||
|
2. 点击 **检查更新**
|
||||||
|
3. 如果有新版本,点击 **立即升级**
|
||||||
|
4. 等待更新完成即可
|
||||||
|
|
||||||
|
### 方式二:Docker 更新
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull wrbug/polyhermes:v1.2.0
|
||||||
|
docker-compose -f docker-compose.prod.yml down
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 安全提醒
|
||||||
|
|
||||||
|
**请务必使用官方 Docker 镜像源!**
|
||||||
|
|
||||||
|
官方镜像:`wrbug/polyhermes`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关链接
|
||||||
|
|
||||||
|
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.2.0
|
||||||
|
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
|
||||||
|
```
|
||||||
|
|
||||||
|
## 发布流程
|
||||||
|
|
||||||
|
1. 验证版本号格式
|
||||||
|
2. 检查 Git 工作目录状态
|
||||||
|
3. 检查 tag 是否已存在
|
||||||
|
4. 生成 Release 内容(中英文)
|
||||||
|
5. 创建本地 tag
|
||||||
|
6. 推送 tag 到远程
|
||||||
|
7. 创建 GitHub Release
|
||||||
|
8. **生成面向用户的公告内容**
|
||||||
|
9. **发布公告到 Issue #1**
|
||||||
|
10. 返回 Release URL 和公告链接
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- Pre-release 版本不会触发 Telegram 通知
|
||||||
|
- GitHub Actions 会自动触发构建流程
|
||||||
|
- 如果 tag 已存在,会提示是否删除并重新创建
|
||||||
|
- **必须**在 Release 创建成功后发布公告到 Issue #1
|
||||||
|
|
||||||
|
## 可选目录说明
|
||||||
|
|
||||||
|
- `scripts/`:包含 `create-release.sh` 发布脚本
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# PolyHermes Release 创建脚本
|
||||||
|
# 功能:创建 tag、推送 tag、创建 GitHub Release(支持 pre-release)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 颜色输出
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# 打印信息
|
||||||
|
info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
warn() {
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 显示使用说明
|
||||||
|
usage() {
|
||||||
|
cat << EOF
|
||||||
|
用法: $0 [选项]
|
||||||
|
|
||||||
|
选项:
|
||||||
|
-t, --tag TAG 版本号 tag(必需,格式:v1.0.0)
|
||||||
|
-T, --title TITLE Release 标题(可选,默认使用 tag)
|
||||||
|
-d, --description DESC Release 描述内容(可选)
|
||||||
|
-f, --description-file FILE 从文件读取 Release 描述(可选)
|
||||||
|
-p, --prerelease 标记为 Pre-release(会自动拼接 -beta 后缀,默认:false)
|
||||||
|
-y, --yes 无交互模式,自动确认所有操作(默认:false)
|
||||||
|
-h, --help 显示此帮助信息
|
||||||
|
|
||||||
|
示例:
|
||||||
|
# 创建正式版本
|
||||||
|
$0 -t v1.0.1 -T "Release v1.0.1" -d "## 新功能\n- 功能1\n- 功能2"
|
||||||
|
|
||||||
|
# 创建 Pre-release(自动拼接 -beta)
|
||||||
|
$0 -t v1.0.1 -T "Release v1.0.1-beta" -d "测试版本" --prerelease
|
||||||
|
# 实际创建的 tag: v1.0.1-beta
|
||||||
|
|
||||||
|
# 从文件读取描述
|
||||||
|
$0 -t v1.0.1 -f CHANGELOG.md --prerelease
|
||||||
|
# 实际创建的 tag: v1.0.1-beta
|
||||||
|
|
||||||
|
# 无交互模式(适合 CI/CD 或自动化脚本)
|
||||||
|
$0 -t v1.0.1 -d "更新内容" --yes
|
||||||
|
|
||||||
|
版本号格式:
|
||||||
|
- 必须格式: v数字.数字.数字 (例如: v1.0.0, v1.10.2, v1.1.12)
|
||||||
|
- 如果指定 --prerelease,会自动拼接 -beta 后缀 (例如: v1.0.1 -> v1.0.1-beta)
|
||||||
|
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# 验证版本号格式(只允许 v数字.数字.数字,不允许后缀)
|
||||||
|
validate_tag() {
|
||||||
|
local tag=$1
|
||||||
|
# 匹配格式:v数字.数字.数字(不允许后缀)
|
||||||
|
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
error "版本号格式不正确:$tag"
|
||||||
|
error "应为 v数字.数字.数字 (例如: v1.0.0, v1.10.2, v1.1.12)"
|
||||||
|
error "如果创建 Pre-release,请使用 --prerelease 参数,脚本会自动拼接 -beta 后缀"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查必要的工具
|
||||||
|
check_requirements() {
|
||||||
|
local auto_yes=$1
|
||||||
|
|
||||||
|
# 检查 git
|
||||||
|
if ! command -v git &> /dev/null; then
|
||||||
|
error "未找到 git 命令,请先安装 git"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查 GitHub CLI
|
||||||
|
if ! command -v gh &> /dev/null; then
|
||||||
|
error "未找到 GitHub CLI (gh) 命令"
|
||||||
|
error "请先安装 GitHub CLI: https://cli.github.com/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查是否已登录 GitHub
|
||||||
|
if ! gh auth status &> /dev/null; then
|
||||||
|
error "未登录 GitHub,请先运行: gh auth login"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查是否有未提交的更改
|
||||||
|
if [[ -n $(git status --porcelain) ]]; then
|
||||||
|
warn "检测到未提交的更改,建议先提交或暂存"
|
||||||
|
if [[ "$auto_yes" == "true" ]]; then
|
||||||
|
info "无交互模式:自动继续"
|
||||||
|
else
|
||||||
|
read -p "是否继续?(y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
info "已取消"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查是否在正确的分支
|
||||||
|
local current_branch=$(git branch --show-current)
|
||||||
|
info "当前分支: $current_branch"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查 tag 是否已存在
|
||||||
|
check_tag_exists() {
|
||||||
|
local tag=$1
|
||||||
|
local auto_yes=$2
|
||||||
|
|
||||||
|
if git rev-parse "$tag" >/dev/null 2>&1; then
|
||||||
|
error "Tag $tag 已存在(本地)"
|
||||||
|
if [[ "$auto_yes" == "true" ]]; then
|
||||||
|
info "无交互模式:自动删除并重新创建"
|
||||||
|
git tag -d "$tag" || true
|
||||||
|
git push origin ":refs/tags/$tag" || true
|
||||||
|
info "已删除旧 tag: $tag"
|
||||||
|
else
|
||||||
|
read -p "是否删除并重新创建?(y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
git tag -d "$tag" || true
|
||||||
|
git push origin ":refs/tags/$tag" || true
|
||||||
|
info "已删除旧 tag: $tag"
|
||||||
|
else
|
||||||
|
error "已取消"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查远程是否存在
|
||||||
|
if git ls-remote --tags origin "$tag" | grep -q "$tag"; then
|
||||||
|
error "Tag $tag 已存在于远程仓库"
|
||||||
|
if [[ "$auto_yes" == "true" ]]; then
|
||||||
|
info "无交互模式:自动删除并重新创建"
|
||||||
|
git tag -d "$tag" || true
|
||||||
|
git push origin ":refs/tags/$tag" || true
|
||||||
|
info "已删除远程 tag: $tag"
|
||||||
|
else
|
||||||
|
read -p "是否删除并重新创建?(y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
git tag -d "$tag" || true
|
||||||
|
git push origin ":refs/tags/$tag" || true
|
||||||
|
info "已删除远程 tag: $tag"
|
||||||
|
else
|
||||||
|
error "已取消"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主函数
|
||||||
|
main() {
|
||||||
|
local TAG=""
|
||||||
|
local TITLE=""
|
||||||
|
local DESCRIPTION=""
|
||||||
|
local DESCRIPTION_FILE=""
|
||||||
|
local PRERELEASE=false
|
||||||
|
local AUTO_YES=false
|
||||||
|
|
||||||
|
# 解析参数
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
-t|--tag)
|
||||||
|
TAG="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-T|--title)
|
||||||
|
TITLE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-d|--description)
|
||||||
|
DESCRIPTION="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-f|--description-file)
|
||||||
|
DESCRIPTION_FILE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-p|--prerelease)
|
||||||
|
PRERELEASE=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-y|--yes)
|
||||||
|
AUTO_YES=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
error "未知参数: $1"
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# 检查必需参数
|
||||||
|
if [[ -z "$TAG" ]]; then
|
||||||
|
error "缺少必需参数: --tag"
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 验证版本号格式(不允许后缀)
|
||||||
|
validate_tag "$TAG"
|
||||||
|
|
||||||
|
# 如果指定了 --prerelease,自动拼接 -beta 后缀
|
||||||
|
local BASE_TAG="$TAG"
|
||||||
|
if [[ "$PRERELEASE" == "true" ]]; then
|
||||||
|
TAG="${BASE_TAG}-beta"
|
||||||
|
info "Pre-release 模式:tag 将自动拼接 -beta 后缀"
|
||||||
|
info "基础版本: $BASE_TAG -> 实际 tag: $TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查工具和环境
|
||||||
|
check_requirements "$AUTO_YES"
|
||||||
|
|
||||||
|
# 检查 tag 是否已存在(使用拼接后的 tag)
|
||||||
|
check_tag_exists "$TAG" "$AUTO_YES"
|
||||||
|
|
||||||
|
# 设置默认标题
|
||||||
|
if [[ -z "$TITLE" ]]; then
|
||||||
|
TITLE="$TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 读取描述内容
|
||||||
|
if [[ -n "$DESCRIPTION_FILE" ]]; then
|
||||||
|
if [[ ! -f "$DESCRIPTION_FILE" ]]; then
|
||||||
|
error "描述文件不存在: $DESCRIPTION_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DESCRIPTION=$(cat "$DESCRIPTION_FILE")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 如果没有描述,使用默认值
|
||||||
|
if [[ -z "$DESCRIPTION" ]]; then
|
||||||
|
if [[ "$PRERELEASE" == "true" ]]; then
|
||||||
|
DESCRIPTION="Pre-release $TAG"
|
||||||
|
else
|
||||||
|
DESCRIPTION="Release $TAG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 显示即将执行的操作
|
||||||
|
echo
|
||||||
|
info "========================================="
|
||||||
|
info " PolyHermes Release 创建"
|
||||||
|
info "========================================="
|
||||||
|
info "Tag: $TAG"
|
||||||
|
info "Title: $TITLE"
|
||||||
|
info "Pre-release: $PRERELEASE"
|
||||||
|
if [[ "$AUTO_YES" == "true" ]]; then
|
||||||
|
info "模式: 无交互模式(自动确认)"
|
||||||
|
fi
|
||||||
|
info "Description:"
|
||||||
|
echo "$DESCRIPTION" | sed 's/^/ /'
|
||||||
|
info "========================================="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# 确认操作
|
||||||
|
if [[ "$AUTO_YES" == "true" ]]; then
|
||||||
|
info "无交互模式:自动确认创建 Release"
|
||||||
|
else
|
||||||
|
read -p "确认创建 Release?(y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
info "已取消"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. 创建 tag(基于当前 HEAD)
|
||||||
|
info "创建 tag: $TAG"
|
||||||
|
git tag "$TAG"
|
||||||
|
success "Tag 创建成功: $TAG"
|
||||||
|
|
||||||
|
# 2. 推送 tag 到远程
|
||||||
|
info "推送 tag 到远程..."
|
||||||
|
git push origin "$TAG"
|
||||||
|
success "Tag 推送成功"
|
||||||
|
|
||||||
|
# 3. 创建 GitHub Release
|
||||||
|
info "创建 GitHub Release..."
|
||||||
|
|
||||||
|
local RELEASE_ARGS=(
|
||||||
|
"$TAG"
|
||||||
|
--title "$TITLE"
|
||||||
|
--notes "$DESCRIPTION"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$PRERELEASE" == "true" ]]; then
|
||||||
|
RELEASE_ARGS+=(--prerelease)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if gh release create "${RELEASE_ARGS[@]}"; then
|
||||||
|
success "GitHub Release 创建成功!"
|
||||||
|
|
||||||
|
# 获取 release URL
|
||||||
|
local RELEASE_URL=$(gh release view "$TAG" --json url -q .url)
|
||||||
|
info "Release URL: $RELEASE_URL"
|
||||||
|
|
||||||
|
echo
|
||||||
|
success "========================================="
|
||||||
|
success " Release 创建完成!"
|
||||||
|
success "========================================="
|
||||||
|
success "Tag: $TAG"
|
||||||
|
success "Pre-release: $PRERELEASE"
|
||||||
|
success "URL: $RELEASE_URL"
|
||||||
|
success "========================================="
|
||||||
|
echo
|
||||||
|
info "GitHub Actions 将自动触发构建流程"
|
||||||
|
|
||||||
|
if [[ "$PRERELEASE" == "true" ]]; then
|
||||||
|
warn "这是 Pre-release,GitHub Actions 不会发送 Telegram 通知"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
error "GitHub Release 创建失败"
|
||||||
|
error "请手动在 GitHub 上创建 Release: https://github.com/WrBug/PolyHermes/releases/new"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 执行主函数
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: check-i18n-keys
|
||||||
|
description: 检查前端多语言 key 完整性。当用户要求检查 i18n、多语言缺失、翻译 key 或运行 check-i18n 时使用。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Check I18n Keys
|
||||||
|
|
||||||
|
检查前端代码中使用的 i18n key 是否在所有语言文件(zh-CN、zh-TW、en)中存在,并报告缺失或语言间不一致的 key。
|
||||||
|
|
||||||
|
## 使用时机
|
||||||
|
|
||||||
|
- 用户要求「检查多语言」「检查 i18n」「扫一下 key」「多语言缺失」时
|
||||||
|
- 用户要求运行多语言检查或执行 check-i18n 时
|
||||||
|
- 在修改或新增前端文案后,需要确认三语言 key 一致时
|
||||||
|
|
||||||
|
## 指令
|
||||||
|
|
||||||
|
1. **运行检查脚本**(在项目仓库根目录下执行):
|
||||||
|
```bash
|
||||||
|
cd .cursor/skills/frontend/check-i18n-keys/scripts && npm install && npm run check-i18n
|
||||||
|
```
|
||||||
|
首次运行需先 `npm install`,之后可直接 `npm run check-i18n`。
|
||||||
|
|
||||||
|
2. **脚本行为**:
|
||||||
|
- 扫描 `frontend/` 下所有 `.ts`、`.tsx`、`.js`、`.jsx`(排除 node_modules、dist、build、*.d.ts)
|
||||||
|
- 提取代码中 `t('key')` / `t("key")` 的 key
|
||||||
|
- 与 `frontend/src/locales/{zh-CN,zh-TW,en}/common.json` 对比
|
||||||
|
- 报告:代码中使用但某语言 JSON 缺失的 key;某语言有而另一语言没有的 key(不一致)
|
||||||
|
|
||||||
|
3. **结果处理**:
|
||||||
|
- 若有缺失或不一致,退出码为 1;可根据报告在对应 `common.json` 中补全 key
|
||||||
|
- 脚本路径:`scripts/check-i18n-keys.ts`(相对本 skill 根目录)
|
||||||
|
|
||||||
|
## 可选目录说明
|
||||||
|
|
||||||
|
- `scripts/`:可执行检查脚本,Agent 按上述命令调用。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查前端多语言 key 完整性
|
||||||
|
*
|
||||||
|
* 扫描范围:frontend 下所有 .ts / .tsx / .js / .jsx(排除 node_modules、dist、build、*.d.ts)
|
||||||
|
*
|
||||||
|
* 支持的检查:
|
||||||
|
* 1. 代码引用但 JSON 没有:某 ts/js 引用了 t("common.test"),但任意语言 common.json 里没有该 key → 报缺失
|
||||||
|
* 2. 语言间不一致:zh-CN/zh-TW 的 JSON 有 common.xxx,但 en 的没有 → 报不一致(某些语言有、某些没有)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { glob } from 'glob';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
interface CheckResult {
|
||||||
|
usedKeys: Set<string>;
|
||||||
|
localeKeys: {
|
||||||
|
'zh-CN': Set<string>;
|
||||||
|
'zh-TW': Set<string>;
|
||||||
|
'en': Set<string>;
|
||||||
|
};
|
||||||
|
missingKeys: {
|
||||||
|
'zh-CN': string[];
|
||||||
|
'zh-TW': string[];
|
||||||
|
'en': string[];
|
||||||
|
};
|
||||||
|
inconsistentKeys: Array<{
|
||||||
|
key: string;
|
||||||
|
existsIn: string[];
|
||||||
|
missingIn: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCALES = ['zh-CN', 'zh-TW', 'en'] as const;
|
||||||
|
type Locale = typeof LOCALES[number];
|
||||||
|
|
||||||
|
// 颜色输出(如果支持)
|
||||||
|
const colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
cyan: '\x1b[36m',
|
||||||
|
};
|
||||||
|
|
||||||
|
function log(message: string, color: keyof typeof colors = 'reset') {
|
||||||
|
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从代码文件中提取所有 t() 调用中的 key
|
||||||
|
*/
|
||||||
|
function extractKeysFromCode(frontendDir: string): Set<string> {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
|
||||||
|
// 匹配 t('key') 或 t("key") 或 t(`key`)
|
||||||
|
const tPattern = /t\(['"`]([^'"`]+)['"`]\)/g;
|
||||||
|
|
||||||
|
// 扫描所有 TS/JS 文件(含 .ts .tsx .js .jsx),排除 node_modules、dist、build、*.d.ts
|
||||||
|
const files = glob.sync('**/*.{ts,tsx,js,jsx}', {
|
||||||
|
cwd: frontendDir,
|
||||||
|
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/*.d.ts'],
|
||||||
|
absolute: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(file, 'utf-8');
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = tPattern.exec(content)) !== null) {
|
||||||
|
const key = match[1];
|
||||||
|
// 过滤掉模板字符串中的变量(如 t(`key.${variable}`))
|
||||||
|
if (!key.includes('${') && !key.includes('${')) {
|
||||||
|
keys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading file ${file}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载语言文件
|
||||||
|
*/
|
||||||
|
function loadLocaleFile(localeDir: string, locale: Locale): Record<string, any> {
|
||||||
|
const filePath = path.join(localeDir, locale, 'common.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
log(`⚠️ 语言文件不存在: ${filePath}`, 'yellow');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch (error) {
|
||||||
|
log(`❌ 解析语言文件失败 ${filePath}: ${error}`, 'red');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展平嵌套对象为点分隔的 key 路径
|
||||||
|
*/
|
||||||
|
function flattenKeys(obj: Record<string, any>, prefix = ''): Set<string> {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||||
|
|
||||||
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||||
|
// 递归处理嵌套对象
|
||||||
|
const nestedKeys = flattenKeys(value, fullKey);
|
||||||
|
nestedKeys.forEach(k => keys.add(k));
|
||||||
|
} else {
|
||||||
|
// 叶子节点
|
||||||
|
keys.add(fullKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 key 完整性
|
||||||
|
*/
|
||||||
|
function checkKeys(usedKeys: Set<string>, localeKeys: Record<Locale, Set<string>>): CheckResult {
|
||||||
|
const result: CheckResult = {
|
||||||
|
usedKeys,
|
||||||
|
localeKeys,
|
||||||
|
missingKeys: {
|
||||||
|
'zh-CN': [],
|
||||||
|
'zh-TW': [],
|
||||||
|
'en': [],
|
||||||
|
},
|
||||||
|
inconsistentKeys: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查每个语言文件中缺失的 key
|
||||||
|
for (const locale of LOCALES) {
|
||||||
|
for (const key of usedKeys) {
|
||||||
|
if (!localeKeys[locale].has(key)) {
|
||||||
|
result.missingKeys[locale].push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查不一致的 key(某些语言有,某些没有)
|
||||||
|
const allLocaleKeys = new Set<string>();
|
||||||
|
LOCALES.forEach(locale => {
|
||||||
|
localeKeys[locale].forEach(key => allLocaleKeys.add(key));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const key of allLocaleKeys) {
|
||||||
|
const existsIn: Locale[] = [];
|
||||||
|
const missingIn: Locale[] = [];
|
||||||
|
|
||||||
|
for (const locale of LOCALES) {
|
||||||
|
if (localeKeys[locale].has(key)) {
|
||||||
|
existsIn.push(locale);
|
||||||
|
} else {
|
||||||
|
missingIn.push(locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果某些语言有,某些没有,则不一致
|
||||||
|
if (existsIn.length > 0 && existsIn.length < LOCALES.length) {
|
||||||
|
result.inconsistentKeys.push({
|
||||||
|
key,
|
||||||
|
existsIn,
|
||||||
|
missingIn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打印检查报告
|
||||||
|
*/
|
||||||
|
function printReport(result: CheckResult): void {
|
||||||
|
log('\n=== 多语言 Key 检查报告 ===\n', 'cyan');
|
||||||
|
|
||||||
|
// 统计信息
|
||||||
|
log('📊 统计信息:', 'blue');
|
||||||
|
log(` - 代码中使用的 key 数量: ${result.usedKeys.size}`);
|
||||||
|
log(` - zh-CN 语言文件 key 数量: ${result.localeKeys['zh-CN'].size}`);
|
||||||
|
log(` - zh-TW 语言文件 key 数量: ${result.localeKeys['zh-TW'].size}`);
|
||||||
|
log(` - en 语言文件 key 数量: ${result.localeKeys['en'].size}`);
|
||||||
|
log('');
|
||||||
|
|
||||||
|
// 缺失的 key
|
||||||
|
let hasMissing = false;
|
||||||
|
for (const locale of LOCALES) {
|
||||||
|
if (result.missingKeys[locale].length > 0) {
|
||||||
|
hasMissing = true;
|
||||||
|
log(`❌ 缺失的 Key (代码中使用但 ${locale} 语言文件中不存在):`, 'red');
|
||||||
|
result.missingKeys[locale].forEach(key => {
|
||||||
|
log(` - ${key}`, 'red');
|
||||||
|
});
|
||||||
|
log('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不一致的 key
|
||||||
|
if (result.inconsistentKeys.length > 0) {
|
||||||
|
log('⚠️ 不一致的 Key (某些语言文件有,某些没有):', 'yellow');
|
||||||
|
result.inconsistentKeys.forEach(({ key, existsIn, missingIn }) => {
|
||||||
|
log(` - ${key}`, 'yellow');
|
||||||
|
log(` 存在于: ${existsIn.join(', ')}`, 'yellow');
|
||||||
|
log(` 缺失于: ${missingIn.join(', ')}`, 'yellow');
|
||||||
|
});
|
||||||
|
log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 总结
|
||||||
|
const hasErrors = hasMissing || result.inconsistentKeys.length > 0;
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
log('❌ 检查失败:发现缺失或不一致的 key', 'red');
|
||||||
|
} else {
|
||||||
|
log('✅ 检查通过:所有 key 都完整且一致', 'green');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主函数
|
||||||
|
*/
|
||||||
|
function main(): void {
|
||||||
|
// 脚本位于 .cursor/skills/frontend/check-i18n-keys/scripts/,向上到项目根
|
||||||
|
const scriptDir = __dirname;
|
||||||
|
const skillDir = path.resolve(scriptDir, '..');
|
||||||
|
const frontendSkillDir = path.resolve(skillDir, '..');
|
||||||
|
const repoRoot = path.resolve(frontendSkillDir, '../../..');
|
||||||
|
const frontendDir = path.join(repoRoot, 'frontend');
|
||||||
|
const localesDir = path.join(frontendDir, 'src', 'locales');
|
||||||
|
|
||||||
|
// 检查目录是否存在
|
||||||
|
if (!fs.existsSync(frontendDir)) {
|
||||||
|
log(`❌ 前端目录不存在: ${frontendDir}`, 'red');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(localesDir)) {
|
||||||
|
log(`❌ 语言文件目录不存在: ${localesDir}`, 'red');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
log('🔍 开始检查多语言 key...\n', 'cyan');
|
||||||
|
|
||||||
|
// 1. 提取代码中使用的 key
|
||||||
|
log('📝 扫描代码文件...', 'blue');
|
||||||
|
const usedKeys = extractKeysFromCode(frontendDir);
|
||||||
|
log(` 找到 ${usedKeys.size} 个使用的 key\n`, 'green');
|
||||||
|
|
||||||
|
// 2. 加载语言文件
|
||||||
|
log('📚 加载语言文件...', 'blue');
|
||||||
|
const localeKeys: Record<Locale, Set<string>> = {
|
||||||
|
'zh-CN': new Set(),
|
||||||
|
'zh-TW': new Set(),
|
||||||
|
'en': new Set(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const locale of LOCALES) {
|
||||||
|
const localeObj = loadLocaleFile(localesDir, locale);
|
||||||
|
localeKeys[locale] = flattenKeys(localeObj);
|
||||||
|
log(` ${locale}: ${localeKeys[locale].size} 个 key`, 'green');
|
||||||
|
}
|
||||||
|
log('');
|
||||||
|
|
||||||
|
// 3. 检查
|
||||||
|
log('🔎 检查 key 完整性...', 'blue');
|
||||||
|
const result = checkKeys(usedKeys, localeKeys);
|
||||||
|
|
||||||
|
// 4. 输出报告
|
||||||
|
printReport(result);
|
||||||
|
|
||||||
|
// 5. 退出码
|
||||||
|
const hasErrors =
|
||||||
|
result.missingKeys['zh-CN'].length > 0 ||
|
||||||
|
result.missingKeys['zh-TW'].length > 0 ||
|
||||||
|
result.missingKeys['en'].length > 0 ||
|
||||||
|
result.inconsistentKeys.length > 0;
|
||||||
|
|
||||||
|
process.exit(hasErrors ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行
|
||||||
|
main();
|
||||||
|
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
{
|
||||||
|
"name": "cursor-skill-frontend-scripts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "cursor-skill-frontend-scripts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"glob": "^13.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/balanced-match": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/brace-expansion": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/balanced-match": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "20.19.33",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
||||||
|
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
|
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.27.3",
|
||||||
|
"@esbuild/android-arm": "0.27.3",
|
||||||
|
"@esbuild/android-arm64": "0.27.3",
|
||||||
|
"@esbuild/android-x64": "0.27.3",
|
||||||
|
"@esbuild/darwin-arm64": "0.27.3",
|
||||||
|
"@esbuild/darwin-x64": "0.27.3",
|
||||||
|
"@esbuild/freebsd-arm64": "0.27.3",
|
||||||
|
"@esbuild/freebsd-x64": "0.27.3",
|
||||||
|
"@esbuild/linux-arm": "0.27.3",
|
||||||
|
"@esbuild/linux-arm64": "0.27.3",
|
||||||
|
"@esbuild/linux-ia32": "0.27.3",
|
||||||
|
"@esbuild/linux-loong64": "0.27.3",
|
||||||
|
"@esbuild/linux-mips64el": "0.27.3",
|
||||||
|
"@esbuild/linux-ppc64": "0.27.3",
|
||||||
|
"@esbuild/linux-riscv64": "0.27.3",
|
||||||
|
"@esbuild/linux-s390x": "0.27.3",
|
||||||
|
"@esbuild/linux-x64": "0.27.3",
|
||||||
|
"@esbuild/netbsd-arm64": "0.27.3",
|
||||||
|
"@esbuild/netbsd-x64": "0.27.3",
|
||||||
|
"@esbuild/openbsd-arm64": "0.27.3",
|
||||||
|
"@esbuild/openbsd-x64": "0.27.3",
|
||||||
|
"@esbuild/openharmony-arm64": "0.27.3",
|
||||||
|
"@esbuild/sunos-x64": "0.27.3",
|
||||||
|
"@esbuild/win32-arm64": "0.27.3",
|
||||||
|
"@esbuild/win32-ia32": "0.27.3",
|
||||||
|
"@esbuild/win32-x64": "0.27.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-tsconfig": {
|
||||||
|
"version": "4.13.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||||
|
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/glob": {
|
||||||
|
"version": "13.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz",
|
||||||
|
"integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"minimatch": "^10.1.2",
|
||||||
|
"minipass": "^7.1.2",
|
||||||
|
"path-scurry": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "11.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz",
|
||||||
|
"integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimatch": {
|
||||||
|
"version": "10.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz",
|
||||||
|
"integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/brace-expansion": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minipass": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-scurry": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lru-cache": "^11.0.0",
|
||||||
|
"minipass": "^7.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/resolve-pkg-maps": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.27.0",
|
||||||
|
"get-tsconfig": "^4.7.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "cursor-skill-frontend-scripts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"check-i18n": "tsx check-i18n-keys.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"glob": "^13.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"downlevelIteration": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
|
||||||
+2
-2
@@ -14,11 +14,11 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# 构建产物
|
# 构建产物
|
||||||
backend/build/
|
# 注意:frontend/dist 和 backend/build/libs 在使用 BUILD_IN_DOCKER=false 时是必需的
|
||||||
|
# 所以不能忽略它们。在 BUILD_IN_DOCKER=true 时,它们会被 Docker 内部编译覆盖
|
||||||
backend/.gradle/
|
backend/.gradle/
|
||||||
backend/out/
|
backend/out/
|
||||||
backend/bin/
|
backend/bin/
|
||||||
frontend/dist/
|
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/.vite/
|
frontend/.vite/
|
||||||
frontend/.cache/
|
frontend/.cache/
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
---
|
||||||
|
name: 🤖 Bug Report for AI Fix / AI Bug 报告
|
||||||
|
description: Bug 报告模板(提交后请手动添加 'fix via ai' 标签触发自动修复)
|
||||||
|
title: '[Bug] / '
|
||||||
|
assignees: []
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Bug Report Template 🐛
|
||||||
|
|
||||||
|
本模板用于报告 PolyHermes 项目的 bug。
|
||||||
|
|
||||||
|
⚠️ **Important / 重要提示**:
|
||||||
|
- After submission, if you need AI auto-fix, please manually add the `fix via ai` label
|
||||||
|
/ 提交后,如需 AI 自动修复,请手动添加 `fix via ai` 标签
|
||||||
|
- AI fixes will be on the `fix_issues_by_ai` branch / AI 修复将在 `fix_issues_by_ai` 分支上进行
|
||||||
|
- All AI fixes require human review before merging / 所有 AI 修复需要人工审核后才能合并
|
||||||
|
- Security vulnerabilities, database migrations, major changes are not recommended for AI auto-fix
|
||||||
|
/ 涉及安全漏洞、数据库迁移、重大变更等问题不建议使用 AI 自动修复
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
本模板用于报告 PolyHermes 项目的 bug。
|
||||||
|
|
||||||
|
This template is for reporting bugs in the PolyHermes project.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: 📝 Bug Description / Bug 描述
|
||||||
|
description: Clearly and concisely describe the bug / 清晰简洁地描述这个 bug
|
||||||
|
placeholder: Describe the bug you encountered / 描述你遇到的问题...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: type
|
||||||
|
attributes:
|
||||||
|
label: 🎯 Bug Type / 问题类型
|
||||||
|
description: Select the type of bug / 选择问题类型
|
||||||
|
options:
|
||||||
|
- Frontend bug (UI/UX/interaction) / 前端 bug (UI/UX/交互问题)
|
||||||
|
- Backend bug (API/logic/data) / 后端 bug (API/逻辑/数据处理)
|
||||||
|
- Database issue / 数据库问题
|
||||||
|
- Performance issue / 性能问题
|
||||||
|
- Configuration/Deployment / 配置/部署问题
|
||||||
|
- Documentation / 文档问题
|
||||||
|
- Other / 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: scope
|
||||||
|
attributes:
|
||||||
|
label: 📍 Affected Scope / 影响范围
|
||||||
|
description: Select the scope of impact / 选择问题影响范围
|
||||||
|
options:
|
||||||
|
- Specific page/function only / 仅影响特定页面/功能
|
||||||
|
- Entire system / 影响整个系统
|
||||||
|
- Specific user role / 影响特定用户角色
|
||||||
|
- Only in specific environment / 仅在特定环境下重现
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: 🔍 Steps to Reproduce / 复现步骤
|
||||||
|
description: Provide clear, detailed steps to reproduce the bug / 提供清晰、详细的步骤来重现这个 bug
|
||||||
|
placeholder: |
|
||||||
|
1. Visit page: `...` / 访问页面:`...`
|
||||||
|
2. Click button: `...` / 点击按钮:`...`
|
||||||
|
3. Input data: `...` / 输入数据:`...`
|
||||||
|
4. Submit form: `...` / 提交表单:`...`
|
||||||
|
5. Observe error: `...` / 观察到错误:`...`
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: frequency
|
||||||
|
attributes:
|
||||||
|
label: Reproduction Frequency / 复现频率
|
||||||
|
options:
|
||||||
|
- Always reproducible (100%) / 总是能复现 (100%)
|
||||||
|
- Frequently reproducible (50%+) / 经常能复现 (50%+)
|
||||||
|
- Occasionally reproducible (<50%) / 偶尔能复现 (<50%)
|
||||||
|
- Hard to reproduce / 很难复现
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: 💻 Expected Behavior / 预期行为
|
||||||
|
description: Describe what you expected to happen / 描述你期望发生什么
|
||||||
|
placeholder: What should happen / 应该发生什么...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: ❌ Actual Behavior / 实际行为
|
||||||
|
description: Describe what actually happened / 描述实际发生了什么
|
||||||
|
placeholder: What actually happened / 实际发生了什么...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: 📸 Screenshots / Recordings / 截图/录屏
|
||||||
|
description: If applicable, add screenshots or recordings to illustrate the problem (drag and drop files here)
|
||||||
|
/ 如果适用,添加截图或录屏来说明问题(可以拖拽文件到这里)
|
||||||
|
placeholder: Add screenshots or recordings / 添加截图或录屏...
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: 🌐 Environment / 环境
|
||||||
|
description: Provide relevant environment information / 提供相关环境信息
|
||||||
|
value: |
|
||||||
|
**Browser (for frontend issues) / 浏览器(前端问题):**
|
||||||
|
- Browser: ______ / 浏览器:______
|
||||||
|
- Browser version: ______ / 浏览器版本:______
|
||||||
|
- Operating System: ______ / 操作系统:______
|
||||||
|
|
||||||
|
**Backend Environment (for backend issues) / 后端环境(后端问题):**
|
||||||
|
- Node.js version: ______ / Node.js 版本:______
|
||||||
|
- Database version: ______ / 数据库版本:______
|
||||||
|
- Docker version (if used): ______ / Docker 版本(如果使用):______
|
||||||
|
- Other relevant dependencies: ______ / 其他相关依赖版本:______
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: related-files
|
||||||
|
attributes:
|
||||||
|
label: 📁 Related Files / Code / 相关文件/代码
|
||||||
|
description: Provide relevant file paths or code snippets / 提供可能涉及的文件路径或相关代码片段
|
||||||
|
placeholder: |
|
||||||
|
Possibly related files / 可能涉及的文件:
|
||||||
|
- frontend/src/components/...
|
||||||
|
- backend/src/main/kotlin/...
|
||||||
|
|
||||||
|
Error logs / 错误日志:
|
||||||
|
```
|
||||||
|
Paste error logs here / 粘贴错误日志
|
||||||
|
```
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: suggestions
|
||||||
|
attributes:
|
||||||
|
label: 🎯 Fix Suggestions (Optional) / 修复建议(可选)
|
||||||
|
description: If you have fix ideas, describe them briefly / 如果你有修复思路,可以在这里简单描述
|
||||||
|
placeholder: |
|
||||||
|
Suggest adding ZZZ check in the YYY method of file XXX
|
||||||
|
/ 建议在 XXX 文件的 YYY 方法中,添加 ZZZ 检查
|
||||||
|
...
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: priority
|
||||||
|
attributes:
|
||||||
|
label: 🚨 Priority / 优先级
|
||||||
|
options:
|
||||||
|
- 🔴 High - Blocking core functionality, affects user experience / 高 - 阻塞核心功能,影响用户体验
|
||||||
|
- 🟡 Medium - Limited functionality but not blocking / 中 - 功能受限但不阻塞
|
||||||
|
- 🟢 Low - Minor issue, doesn't affect usage / 低 - 小问题,不影响使用
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: 📝 Additional Information / 补充说明
|
||||||
|
description: Any other information that helps AI understand and fix the issue / 任何其他有助于 AI 理解和修复问题的信息
|
||||||
|
placeholder: |
|
||||||
|
- Was this bug introduced recently? / 这个 bug 是最近引入的吗?
|
||||||
|
- Is it related to a specific PR or commit? / 是否与某个特定的 PR 或 commit 相关?
|
||||||
|
- Does it only occur with specific datasets or users? / 是否只在特定数据集或特定用户情况下出现?
|
||||||
|
/ ...
|
||||||
|
Any other context / 其他任何上下文信息...
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: ai-fix-approval
|
||||||
|
attributes:
|
||||||
|
label: 🤖 AI Auto-Fix Confirmation / AI 自动修复确认
|
||||||
|
description: If you need AI to auto-fix this issue, check the options below (remember to add 'fix via ai' label after submission)
|
||||||
|
/ 如需 AI 自动修复此 issue,请勾选下方选项(提交后记得添加 'fix via ai' 标签)
|
||||||
|
options:
|
||||||
|
- label: |
|
||||||
|
I understand the AI auto-fix workflow and agree to manually review the AI-created PR
|
||||||
|
/ 我已了解 AI 自动修复的工作流程,并同意在 AI 创建 PR 后进行人工审核
|
||||||
|
required: false
|
||||||
|
- label: |
|
||||||
|
This issue is suitable for AI auto-fix (not a security vulnerability, not a database migration, not a major change)
|
||||||
|
/ 此问题适合 AI 自动修复(非安全漏洞、非数据库迁移、非重大变更)
|
||||||
|
required: false
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# PR 合并后自动关闭关联的 Issue
|
||||||
|
# 当 PR 从 ai_fix/N_xxx 分支合并到 main 时,关闭 #N 对应的 Issue(若 PR 描述中未含 Closes #N 则通过分支名解析)
|
||||||
|
# 关闭后发送 Telegram 通知(复用 TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID)
|
||||||
|
|
||||||
|
name: Close issue on PR merge
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close-issue:
|
||||||
|
if: github.event.pull_request.merged == true
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Get PR info
|
||||||
|
id: pr
|
||||||
|
run: |
|
||||||
|
# 从 PR body 查找 Closes #N / Fixes #N(若已有则 GitHub 已自动关 issue,本 step 仅做解析)
|
||||||
|
BODY="${{ github.event.pull_request.body }}"
|
||||||
|
HEAD_REF="${{ github.event.pull_request.head.ref }}"
|
||||||
|
|
||||||
|
# 优先从 PR 描述解析(无匹配时 grep 会 exit 1,需 || true 避免 set -e 导致脚本退出)
|
||||||
|
ISSUE_NUM=$(echo "$BODY" | grep -oE '(Closes|Fixes|Resolves) #([0-9]+)' | head -1 | grep -oE '[0-9]+' || true)
|
||||||
|
if [ -z "$ISSUE_NUM" ]; then
|
||||||
|
# 从分支名解析 ai_fix/N_xxx
|
||||||
|
ISSUE_NUM=$(echo "$HEAD_REF" | sed -n 's|^ai_fix/\([0-9]*\)_.*|\1|p')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$ISSUE_NUM" ]; then
|
||||||
|
echo "ISSUE_NUMBER=" >> $GITHUB_OUTPUT
|
||||||
|
echo "skip=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "未从 PR 描述或分支名解析到 Issue 编号,跳过关闭"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ISSUE_NUMBER=$ISSUE_NUM" >> $GITHUB_OUTPUT
|
||||||
|
echo "skip=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "解析到 Issue #$ISSUE_NUM"
|
||||||
|
|
||||||
|
- name: Get issue details
|
||||||
|
if: steps.pr.outputs.skip != 'true'
|
||||||
|
id: issue
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
ISSUE_NUM="${{ steps.pr.outputs.ISSUE_NUMBER }}"
|
||||||
|
# 获取 issue 标题与 URL(用于 TG 消息)
|
||||||
|
JSON=$(gh issue view "$ISSUE_NUM" --json title,url 2>/dev/null || echo '{"title":"","url":""}')
|
||||||
|
TITLE=$(echo "$JSON" | jq -r '.title')
|
||||||
|
ISSUE_URL=$(echo "$JSON" | jq -r '.url')
|
||||||
|
echo "title<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
echo "$TITLE" >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
echo "url=$ISSUE_URL" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Close issue
|
||||||
|
if: steps.pr.outputs.skip != 'true'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const issueNumber = parseInt('${{ steps.pr.outputs.ISSUE_NUMBER }}', 10);
|
||||||
|
if (!issueNumber || isNaN(issueNumber)) {
|
||||||
|
console.log('No valid issue number, skip');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { data: issue } = await github.rest.issues.get({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issueNumber
|
||||||
|
});
|
||||||
|
if (issue.state === 'open') {
|
||||||
|
await github.rest.issues.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
state: 'closed'
|
||||||
|
});
|
||||||
|
console.log(`Issue #${issueNumber} closed.`);
|
||||||
|
} else {
|
||||||
|
console.log(`Issue #${issueNumber} already closed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Send Telegram notification
|
||||||
|
if: steps.pr.outputs.skip != 'true'
|
||||||
|
env:
|
||||||
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
run: |
|
||||||
|
# 与 docker-build 一致:未配置则跳过
|
||||||
|
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||||
|
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ISSUE_NUM="${{ steps.pr.outputs.ISSUE_NUMBER }}"
|
||||||
|
ISSUE_TITLE="${{ steps.issue.outputs.title }}"
|
||||||
|
ISSUE_URL="${{ steps.issue.outputs.url }}"
|
||||||
|
PR_URL="${{ github.event.pull_request.html_url }}"
|
||||||
|
|
||||||
|
# 与 docker-build 相同的 HTML 消息格式
|
||||||
|
MESSAGE="✅ <b>AI 修复的 Issue 已关闭</b>"$'\n'$'\n'"🔢 <b>Issue:</b> #${ISSUE_NUM} ${ISSUE_TITLE}"$'\n'"📎 <a href=\"${ISSUE_URL}\">查看 Issue</a>"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
|
||||||
|
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n \
|
||||||
|
--arg chat_id "$TELEGRAM_CHAT_ID" \
|
||||||
|
--arg text "$MESSAGE" \
|
||||||
|
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
RESPONSE=$(cat /tmp/telegram_response.json)
|
||||||
|
if echo "$RESPONSE" | grep -q '"ok":true'; then
|
||||||
|
echo "✅ Telegram 通知发送成功"
|
||||||
|
else
|
||||||
|
echo "❌ Telegram 通知发送失败: $RESPONSE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "❌ 发送 Telegram 消息时发生错误"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
@@ -4,38 +4,241 @@ on:
|
|||||||
release:
|
release:
|
||||||
types:
|
types:
|
||||||
- published # 当通过 GitHub Releases 页面创建 release 时触发
|
- published # 当通过 GitHub Releases 页面创建 release 时触发
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: '版本号(例如: v1.0.0)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
tag_name:
|
||||||
|
description: 'Git Tag 名称(留空则使用 version)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # 需要写权限以上传 Assets
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.release.tag_name }} # 使用 release 对应的 tag
|
ref: ${{ github.event.release.tag_name || github.event.inputs.tag_name || github.event.inputs.version || github.ref }}
|
||||||
|
|
||||||
- name: Extract version from release
|
- name: Extract version and check if pre-release
|
||||||
id: extract_version
|
id: extract_version
|
||||||
run: |
|
run: |
|
||||||
# 从 release tag 中提取版本号(例如 v1.0.0 -> 1.0.0)
|
# 从不同事件源提取版本号
|
||||||
TAG_NAME="${{ github.event.release.tag_name }}"
|
if [ "${{ github.event_name }}" = "release" ]; then
|
||||||
if [ -z "$TAG_NAME" ]; then
|
# Release 事件:从 release tag 中提取
|
||||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
TAG_NAME="${{ github.event.release.tag_name }}"
|
||||||
|
IS_PRERELEASE="${{ github.event.release.prerelease }}"
|
||||||
|
else
|
||||||
|
# workflow_dispatch 事件:从输入参数中提取
|
||||||
|
TAG_NAME="${{ github.event.inputs.tag_name }}"
|
||||||
|
if [ -z "$TAG_NAME" ]; then
|
||||||
|
TAG_NAME="${{ github.event.inputs.version }}"
|
||||||
|
fi
|
||||||
|
# 如果仍然为空,尝试从 git ref 中提取
|
||||||
|
if [ -z "$TAG_NAME" ]; then
|
||||||
|
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||||
|
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
|
||||||
|
# 不是 tag,尝试从分支名或 commit SHA 获取
|
||||||
|
TAG_NAME=${GITHUB_REF#refs/heads/}
|
||||||
|
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
|
||||||
|
TAG_NAME="dev-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
echo "⚠️ 未指定版本号,使用临时版本: $TAG_NAME"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
IS_PRERELEASE="false"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 验证版本号格式:v数字.数字.数字[-后缀](例如 v1.0.0, v2.10.102, v1.0.0-beta)
|
# 验证版本号格式:v数字.数字.数字[-后缀](例如 v1.0.0, v2.10.102, v1.0.0-beta)
|
||||||
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
|
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] && [[ ! "$TAG_NAME" =~ ^dev- ]]; then
|
||||||
echo "错误: 版本号格式不正确,应为 v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
|
echo "⚠️ 警告: 版本号格式不符合标准,但仍将继续构建"
|
||||||
exit 1
|
echo " 当前版本号: $TAG_NAME"
|
||||||
|
echo " 标准格式应为: v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
VERSION=${TAG_NAME#v} # 移除 v 前缀
|
VERSION=${TAG_NAME#v} # 移除 v 前缀(如果存在)
|
||||||
|
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "TAG=$TAG_NAME" >> $GITHUB_OUTPUT
|
echo "TAG=$TAG_NAME" >> $GITHUB_OUTPUT
|
||||||
echo "Extracted version: $VERSION"
|
echo "IS_PRERELEASE=$IS_PRERELEASE" >> $GITHUB_OUTPUT
|
||||||
echo "Full tag: $TAG_NAME"
|
|
||||||
|
if [ "$IS_PRERELEASE" = "true" ]; then
|
||||||
|
echo "📋 这是 Pre-release: $TAG_NAME"
|
||||||
|
else
|
||||||
|
echo "📦 这是正式版本: $TAG_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============ 编译前后端产物 ============
|
||||||
|
- name: Setup JDK 17
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
java-version: '17'
|
||||||
|
distribution: 'temurin'
|
||||||
|
|
||||||
|
- name: Build Backend JAR
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
chmod +x gradlew
|
||||||
|
./gradlew bootJar --no-daemon
|
||||||
|
echo "✅ 后端构建完成"
|
||||||
|
ls -lh build/libs/*.jar
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Build Frontend
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.extract_version.outputs.VERSION }}
|
||||||
|
GIT_TAG: ${{ steps.extract_version.outputs.TAG }}
|
||||||
|
GITHUB_REPO_URL: https://github.com/WrBug/PolyHermes
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
echo "✅ 前端构建完成"
|
||||||
|
echo "📦 版本信息: VERSION=${{ steps.extract_version.outputs.VERSION }}, GIT_TAG=${{ steps.extract_version.outputs.TAG }}"
|
||||||
|
du -sh dist/
|
||||||
|
|
||||||
|
# ============ 打包更新包 ============
|
||||||
|
- name: Create Update Package
|
||||||
|
run: |
|
||||||
|
echo "📦 开始打包更新包..."
|
||||||
|
|
||||||
|
# 创建目录结构
|
||||||
|
mkdir -p update-package/backend
|
||||||
|
mkdir -p update-package/frontend
|
||||||
|
|
||||||
|
# 复制后端 JAR
|
||||||
|
cp backend/build/libs/*.jar update-package/backend/polyhermes.jar
|
||||||
|
echo "✅ 后端 JAR 已复制"
|
||||||
|
|
||||||
|
# 复制前端产物
|
||||||
|
cp -r frontend/dist/* update-package/frontend/
|
||||||
|
echo "✅ 前端文件已复制"
|
||||||
|
|
||||||
|
# 创建版本信息文件
|
||||||
|
if [ "${{ github.event_name }}" = "release" ]; then
|
||||||
|
RELEASE_NOTES=$(echo '${{ github.event.release.body }}' | jq -Rs .)
|
||||||
|
else
|
||||||
|
RELEASE_NOTES="\"手动构建 - workflow_dispatch\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > update-package/version.json <<EOF
|
||||||
|
{
|
||||||
|
"version": "${{ steps.extract_version.outputs.VERSION }}",
|
||||||
|
"tag": "${{ steps.extract_version.outputs.TAG }}",
|
||||||
|
"buildTime": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||||
|
"releaseNotes": ${RELEASE_NOTES}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
echo "✅ 版本信息已创建"
|
||||||
|
|
||||||
|
# 打包成 tar.gz
|
||||||
|
cd update-package
|
||||||
|
tar -czf ../polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz .
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo "✅ 打包完成: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz"
|
||||||
|
ls -lh polyhermes-*.tar.gz
|
||||||
|
|
||||||
|
- name: Calculate Checksum
|
||||||
|
id: checksum
|
||||||
|
run: |
|
||||||
|
FILE="polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz"
|
||||||
|
CHECKSUM=$(sha256sum "$FILE" | awk '{print $1}')
|
||||||
|
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_OUTPUT
|
||||||
|
echo "✅ SHA256: $CHECKSUM"
|
||||||
|
echo "$CHECKSUM $FILE" > checksums.txt
|
||||||
|
|
||||||
|
- name: Upload Update Package to Release
|
||||||
|
if: github.event_name == 'release'
|
||||||
|
uses: actions/upload-release-asset@v1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
upload_url: ${{ github.event.release.upload_url }}
|
||||||
|
asset_path: ./polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
|
||||||
|
asset_name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
|
||||||
|
asset_content_type: application/gzip
|
||||||
|
|
||||||
|
- name: Upload Checksums to Release
|
||||||
|
if: github.event_name == 'release'
|
||||||
|
uses: actions/upload-release-asset@v1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
upload_url: ${{ github.event.release.upload_url }}
|
||||||
|
asset_path: ./checksums.txt
|
||||||
|
asset_name: checksums.txt
|
||||||
|
asset_content_type: text/plain
|
||||||
|
|
||||||
|
- name: Upload Update Package as Artifact
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update
|
||||||
|
path: |
|
||||||
|
polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
|
||||||
|
checksums.txt
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
# ============ 发送产物上传成功通知 ============
|
||||||
|
- name: Send Telegram notification (package uploaded)
|
||||||
|
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
|
||||||
|
env:
|
||||||
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
run: |
|
||||||
|
# 检查必要的环境变量
|
||||||
|
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||||
|
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 获取构建信息
|
||||||
|
TAG="${{ steps.extract_version.outputs.TAG }}"
|
||||||
|
|
||||||
|
if [ "${{ github.event_name }}" = "release" ]; then
|
||||||
|
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||||
|
MESSAGE="✅ <b>PolyHermes Package Built</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"🔧 <b>Type:</b> Online Update"$'\n'"🔗 <a href=\"${RELEASE_URL}\">View Release</a>"$'\n'"📍 <b>Update Path:</b> System Management → Overview → Check for Updates"$'\n'$'\n'"🐳 Building Docker image..."
|
||||||
|
else
|
||||||
|
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
|
MESSAGE="✅ <b>PolyHermes Package Built</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"🔧 <b>Type:</b> Online Update"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">View Workflow</a>"$'\n'$'\n'"🐳 Building Docker image..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n \
|
||||||
|
--arg chat_id "$TELEGRAM_CHAT_ID" \
|
||||||
|
--arg text "$MESSAGE" \
|
||||||
|
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
|
||||||
|
|
||||||
|
# 检查发送结果
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
RESPONSE=$(cat /tmp/telegram_response.json)
|
||||||
|
if echo "$RESPONSE" | grep -q '"ok":true'; then
|
||||||
|
echo "✅ Telegram 通知发送成功"
|
||||||
|
else
|
||||||
|
echo "❌ Telegram 通知发送失败: $RESPONSE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "❌ 发送 Telegram 消息时发生错误"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============ Docker 构建 ============
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -48,7 +251,24 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Prepare Docker build context
|
||||||
|
run: |
|
||||||
|
echo "📦 准备 Docker 构建上下文..."
|
||||||
|
# 确保构建产物存在且可访问
|
||||||
|
if [ ! -d "frontend/dist" ]; then
|
||||||
|
echo "❌ 错误:frontend/dist 不存在"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -d "backend/build/libs" ] || [ -z "$(ls -A backend/build/libs/*.jar 2>/dev/null)" ]; then
|
||||||
|
echo "❌ 错误:backend/build/libs/*.jar 不存在"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ 构建产物已准备好"
|
||||||
|
ls -lh frontend/dist/ | head -5
|
||||||
|
ls -lh backend/build/libs/*.jar
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
|
id: docker_build
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -58,10 +278,59 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: |
|
tags: |
|
||||||
wrbug/polyhermes:${{ steps.extract_version.outputs.TAG }}
|
wrbug/polyhermes:${{ steps.extract_version.outputs.TAG }}
|
||||||
wrbug/polyhermes:latest
|
${{ steps.extract_version.outputs.IS_PRERELEASE == 'false' && 'wrbug/polyhermes:latest' || '' }}
|
||||||
build-args: |
|
build-args: |
|
||||||
|
BUILD_IN_DOCKER=false
|
||||||
VERSION=${{ steps.extract_version.outputs.VERSION }}
|
VERSION=${{ steps.extract_version.outputs.VERSION }}
|
||||||
GIT_TAG=${{ steps.extract_version.outputs.TAG }}
|
GIT_TAG=${{ steps.extract_version.outputs.TAG }}
|
||||||
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
||||||
cache-from: type=registry,ref=wrbug/polyhermes:latest
|
cache-from: type=registry,ref=wrbug/polyhermes:latest
|
||||||
cache-to: type=inline
|
cache-to: type=inline
|
||||||
|
|
||||||
|
# ============ 发送 Docker 构建成功通知 ============
|
||||||
|
- name: Send Telegram notification (Docker build completed)
|
||||||
|
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
|
||||||
|
env:
|
||||||
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
run: |
|
||||||
|
# 检查必要的环境变量
|
||||||
|
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||||
|
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 获取构建信息
|
||||||
|
VERSION="${{ steps.extract_version.outputs.VERSION }}"
|
||||||
|
TAG="${{ steps.extract_version.outputs.TAG }}"
|
||||||
|
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
|
||||||
|
|
||||||
|
if [ "${{ github.event_name }}" = "release" ]; then
|
||||||
|
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||||
|
MESSAGE="🐳 <b>Docker Image Built Successfully</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"📦 <b>Image:</b> <code>wrbug/polyhermes:${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">View Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker Deployment Guide</a>"
|
||||||
|
else
|
||||||
|
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
|
MESSAGE="🐳 <b>Docker Image Built Successfully</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"📦 <b>Image:</b> <code>wrbug/polyhermes:${TAG}</code>"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">View Workflow</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker Deployment Guide</a>"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n \
|
||||||
|
--arg chat_id "$TELEGRAM_CHAT_ID" \
|
||||||
|
--arg text "$MESSAGE" \
|
||||||
|
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
|
||||||
|
|
||||||
|
# 检查发送结果
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
RESPONSE=$(cat /tmp/telegram_response.json)
|
||||||
|
if echo "$RESPONSE" | grep -q '"ok":true'; then
|
||||||
|
echo "✅ Telegram 通知发送成功"
|
||||||
|
else
|
||||||
|
echo "❌ Telegram 通知发送失败: $RESPONSE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "❌ 发送 Telegram 消息时发生错误"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|||||||
+7
-3
@@ -17,14 +17,16 @@ backend/out/
|
|||||||
backend/*.log
|
backend/*.log
|
||||||
backend/gradle-app.setting
|
backend/gradle-app.setting
|
||||||
backend/.gradle
|
backend/.gradle
|
||||||
backend/gradle-wrapper.jar
|
# 注意:gradle-wrapper.jar 应该被提交,不要忽略
|
||||||
|
# backend/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
polyhub/
|
||||||
# Kotlin
|
# Kotlin
|
||||||
*.kt.bak
|
*.kt.bak
|
||||||
*.class
|
*.class
|
||||||
|
|
||||||
# Java
|
# Java
|
||||||
*.jar
|
*.jar
|
||||||
|
!backend/gradle/wrapper/gradle-wrapper.jar # Gradle Wrapper JAR 应该被提交
|
||||||
*.war
|
*.war
|
||||||
*.ear
|
*.ear
|
||||||
*.class
|
*.class
|
||||||
@@ -109,4 +111,6 @@ __pycache__/
|
|||||||
# Submodules and external dependencies
|
# Submodules and external dependencies
|
||||||
clob-client/
|
clob-client/
|
||||||
builder-relayer-client/
|
builder-relayer-client/
|
||||||
|
landing-page/
|
||||||
|
clob-client-v2/
|
||||||
|
settings.local.json
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
# 🎉 v1.0.1 版本发布公告
|
|
||||||
|
|
||||||
## 📅 发布日期
|
|
||||||
|
|
||||||
2025年12月(具体日期待定)
|
|
||||||
|
|
||||||
## 🚀 主要更新
|
|
||||||
|
|
||||||
### 性能优化
|
|
||||||
|
|
||||||
#### 📊 订单簿请求优化
|
|
||||||
- 优化订单簿获取逻辑,仅在需要时请求,避免不必要的 API 调用
|
|
||||||
- 当未配置需要订单簿的过滤条件(价差、订单深度)时,跳过订单簿获取
|
|
||||||
- 订单簿只请求一次,在过滤检查中复用,提高性能
|
|
||||||
|
|
||||||
#### 💰 卖出价格计算优化
|
|
||||||
- 卖出改为市价卖出:优先使用订单簿的 bestBid(最高买入价)
|
|
||||||
- 如果 bestBid 获取失败,自动降级使用 Leader 的交易价格
|
|
||||||
- 卖出价格固定按 90% 计算,不再使用价格容忍度配置
|
|
||||||
- 提高卖出订单的成交率
|
|
||||||
|
|
||||||
#### ⚙️ 价格容忍度默认值
|
|
||||||
- 如果价格容忍度配置为 0,自动使用默认值 5%
|
|
||||||
- 确保买入订单能够正常应用价格调整,提高成交率
|
|
||||||
|
|
||||||
### 代码优化
|
|
||||||
|
|
||||||
#### 🎯 使用枚举优化过滤逻辑
|
|
||||||
- 新增 `FilterResult` 数据类和 `FilterStatus` 枚举
|
|
||||||
- 使用类型安全的枚举替代字符串判断,提高代码可维护性
|
|
||||||
- 移除无用字段 `isBuyOrder` 参数
|
|
||||||
- 优化 `checkFilters` 方法返回值,使用数据类封装结果
|
|
||||||
|
|
||||||
#### 📝 代码结构优化
|
|
||||||
- 减少 if-else 嵌套,使用 Kotlin 链式调用和空安全操作符
|
|
||||||
- 优化卖出价格计算逻辑,使用 `runCatching` 和 `?.let` 简化代码
|
|
||||||
- 提取公共方法 `calculateFallbackSellPrice`,提高代码复用性
|
|
||||||
|
|
||||||
### 功能改进
|
|
||||||
|
|
||||||
#### 🗑️ 移除最小订单簿深度功能
|
|
||||||
- 移除 `minOrderbookDepth`(最小订单簿深度)配置项
|
|
||||||
- 简化过滤逻辑,只保留 `minOrderDepth`(最小订单深度)
|
|
||||||
- 更新前端界面,移除相关配置项和提示文案
|
|
||||||
|
|
||||||
#### 📈 优化最小订单深度逻辑
|
|
||||||
- 修改 `minOrderDepth` 检查逻辑,检查所有方向(买盘+卖盘)的总深度
|
|
||||||
- 不再区分买卖方向,提供更全面的市场流动性评估
|
|
||||||
|
|
||||||
#### 🎯 市场结算判断优化
|
|
||||||
- 添加市场已关闭时的结算判断逻辑
|
|
||||||
- 如果市场已关闭且该 outcome 赢了(价格 >= 0.99),返回价格为 1
|
|
||||||
- 如果市场已关闭且该 outcome 输了(价格 <= 0.01),返回价格为 0
|
|
||||||
- 当没有仓位但有未完成订单时,正确判断市场结算状态并设置价格
|
|
||||||
|
|
||||||
### Bug 修复
|
|
||||||
|
|
||||||
- ✅ 修复盈亏和持仓价值计算逻辑未实现的问题
|
|
||||||
- ✅ 修复过滤检查中订单簿可能重复请求的问题
|
|
||||||
- ✅ 修复健康检查路径不一致问题(从 `/api/health` 改为 `/api/system/health`)
|
|
||||||
|
|
||||||
## 📦 如何更新
|
|
||||||
|
|
||||||
### Docker 部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 拉取最新镜像
|
|
||||||
docker pull wrbug/polyhermes:v1.0.1
|
|
||||||
|
|
||||||
# 或使用 latest 标签
|
|
||||||
docker pull wrbug/polyhermes:latest
|
|
||||||
|
|
||||||
# 重启容器
|
|
||||||
docker-compose down
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 源码部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 拉取最新代码
|
|
||||||
git fetch origin
|
|
||||||
git checkout v1.0.1
|
|
||||||
|
|
||||||
# 重新构建
|
|
||||||
cd backend
|
|
||||||
./gradlew bootJar
|
|
||||||
|
|
||||||
# 重启服务
|
|
||||||
# 根据您的部署方式重启服务
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📚 文档更新
|
|
||||||
|
|
||||||
- 更新前端多语言文案,优化提示信息
|
|
||||||
- 移除最小订单簿深度相关的文档说明
|
|
||||||
|
|
||||||
## 🔗 相关链接
|
|
||||||
|
|
||||||
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.0.1
|
|
||||||
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.0.0...v1.0.1
|
|
||||||
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
|
|
||||||
|
|
||||||
## ⚠️ 重要提醒
|
|
||||||
|
|
||||||
**请务必使用官方 Docker 镜像源,避免财产损失!**
|
|
||||||
|
|
||||||
### ✅ 官方 Docker Hub 镜像
|
|
||||||
|
|
||||||
**官方镜像地址**:`wrbug/polyhermes`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ✅ 正确:使用官方镜像
|
|
||||||
docker pull wrbug/polyhermes:v1.0.1
|
|
||||||
|
|
||||||
# ❌ 错误:不要使用其他来源的镜像
|
|
||||||
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🔗 官方渠道
|
|
||||||
|
|
||||||
请通过以下**唯一官方渠道**获取 PolyHermes:
|
|
||||||
|
|
||||||
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
|
||||||
* **Twitter**:@polyhermes
|
|
||||||
* **Telegram 群组**:加入群组
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
|
|
||||||
|
|
||||||
**💬 如有问题或建议,欢迎在 GitHub Issues 中反馈。**
|
|
||||||
|
|
||||||
+88
-31
@@ -1,6 +1,12 @@
|
|||||||
# 多阶段构建:前后端一体化部署
|
# 多阶段构建:前后端一体化部署(支持混合编译)
|
||||||
# 阶段1:构建前端
|
# 构建参数:控制是否在 Docker 内编译
|
||||||
|
# - BUILD_IN_DOCKER=true (默认): Docker 内部编译(本地开发)
|
||||||
|
# - BUILD_IN_DOCKER=false: 使用外部产物(GitHub Actions)
|
||||||
|
ARG BUILD_IN_DOCKER=true
|
||||||
|
|
||||||
|
# ==================== 阶段1:构建前端 ====================
|
||||||
FROM node:18-alpine AS frontend-build
|
FROM node:18-alpine AS frontend-build
|
||||||
|
ARG BUILD_IN_DOCKER
|
||||||
|
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
@@ -13,19 +19,37 @@ ARG GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
|||||||
ENV VERSION=${VERSION}
|
ENV VERSION=${VERSION}
|
||||||
ENV GIT_TAG=${GIT_TAG}
|
ENV GIT_TAG=${GIT_TAG}
|
||||||
ENV GITHUB_REPO_URL=${GITHUB_REPO_URL}
|
ENV GITHUB_REPO_URL=${GITHUB_REPO_URL}
|
||||||
|
# 复制前端文件(先复制 package.json 以利用 Docker 缓存)
|
||||||
# 复制前端文件
|
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
|
# 条件:仅在 Docker 内部编译时安装依赖
|
||||||
|
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||||
|
npm ci; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 复制所有前端源文件
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
|
|
||||||
# 构建前端(使用相对路径,通过 Nginx 代理)
|
# 条件:仅在 Docker 内部编译时执行构建
|
||||||
# 版本号会通过环境变量注入到构建产物中
|
# 如果 BUILD_IN_DOCKER=false,需要确保构建上下文中存在 frontend/dist
|
||||||
RUN npm run build
|
# 注意:COPY frontend/ ./ 已经复制了整个 frontend 目录(包括 dist,如果存在)
|
||||||
|
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||||
|
echo "🔨 Docker 内部编译前端..."; \
|
||||||
|
npm run build; \
|
||||||
|
else \
|
||||||
|
echo "⏭️ 使用外部产物..."; \
|
||||||
|
if [ ! -d "dist" ] || [ -z "$(ls -A dist 2>/dev/null)" ]; then \
|
||||||
|
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 frontend/dist"; \
|
||||||
|
echo " 请先执行: cd frontend && npm install && npm run build"; \
|
||||||
|
exit 1; \
|
||||||
|
else \
|
||||||
|
echo "✅ 找到外部构建的前端产物"; \
|
||||||
|
fi; \
|
||||||
|
fi
|
||||||
|
|
||||||
# 阶段2:构建后端
|
# ==================== 阶段2:构建后端 ====================
|
||||||
FROM gradle:8.5-jdk17 AS backend-build
|
FROM gradle:8.5-jdk17 AS backend-build
|
||||||
|
ARG BUILD_IN_DOCKER
|
||||||
|
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
|
|
||||||
@@ -33,60 +57,93 @@ WORKDIR /app/backend
|
|||||||
COPY backend/build.gradle.kts backend/settings.gradle.kts ./
|
COPY backend/build.gradle.kts backend/settings.gradle.kts ./
|
||||||
COPY backend/gradle ./gradle
|
COPY backend/gradle ./gradle
|
||||||
|
|
||||||
# 下载依赖(利用 Docker 缓存)
|
# 条件:仅在 Docker 内部编译时下载依赖
|
||||||
RUN gradle dependencies --no-daemon || true
|
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||||
|
gradle dependencies --no-daemon || true; \
|
||||||
|
fi
|
||||||
|
|
||||||
# 复制源代码
|
# 复制源代码
|
||||||
COPY backend/src ./src
|
COPY backend/src ./src
|
||||||
|
|
||||||
# 构建应用
|
# 尝试复制外部构建的 JAR(如果存在)
|
||||||
RUN gradle bootJar --no-daemon
|
# 注意:COPY 指令如果源不存在会失败
|
||||||
|
# GitHub Actions 使用 BUILD_IN_DOCKER=false,会先构建产物,所以 backend/build 应该存在
|
||||||
|
# 本地开发使用 BUILD_IN_DOCKER=true,会在 Docker 内编译,所以 backend/build 可能不存在
|
||||||
|
# 解决方案:先复制整个 backend 目录(包括 build,如果存在),然后只使用需要的部分
|
||||||
|
# 使用 .dockerignore 确保不会复制不需要的文件(如 .gradle、out、bin 等)
|
||||||
|
COPY backend/build ./build-external
|
||||||
|
|
||||||
# 阶段3:运行环境
|
# 处理外部构建的 JAR(如果存在)
|
||||||
|
RUN if [ -d "build-external/libs" ] && [ -n "$(ls -A build-external/libs/*.jar 2>/dev/null)" ]; then \
|
||||||
|
echo "📦 找到外部构建的后端产物,复制到 build/libs..."; \
|
||||||
|
mkdir -p build/libs; \
|
||||||
|
cp build-external/libs/*.jar build/libs/; \
|
||||||
|
rm -rf build-external; \
|
||||||
|
else \
|
||||||
|
echo "⏭️ 未找到外部构建的 JAR,将在 Docker 内编译"; \
|
||||||
|
rm -rf build-external; \
|
||||||
|
mkdir -p build/libs; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 条件:仅在 Docker 内部编译时执行构建(会覆盖外部产物)
|
||||||
|
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||||
|
echo "🔨 Docker 内部编译后端..."; \
|
||||||
|
gradle bootJar --no-daemon; \
|
||||||
|
else \
|
||||||
|
echo "⏭️ 使用外部产物"; \
|
||||||
|
if [ -z "$(ls -A build/libs/*.jar 2>/dev/null)" ]; then \
|
||||||
|
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 backend/build/libs/*.jar"; \
|
||||||
|
echo " 请先执行: cd backend && ./gradlew bootJar"; \
|
||||||
|
exit 1; \
|
||||||
|
else \
|
||||||
|
echo "✅ 使用外部构建的后端产物"; \
|
||||||
|
fi; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ==================== 阶段3:运行环境 ====================
|
||||||
FROM eclipse-temurin:17-jre-jammy
|
FROM eclipse-temurin:17-jre-jammy
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 安装 Nginx 和必要的工具
|
# 安装 Nginx、Python 和必要的工具
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y nginx curl && \
|
apt-get install -y nginx curl tzdata jq python3 python3-flask python3-requests && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
rm -rf /etc/nginx/sites-enabled/default
|
rm -rf /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
# 从构建阶段复制文件
|
# 从构建阶段复制文件
|
||||||
|
# 当 BUILD_IN_DOCKER=false 时,构建阶段已经复制了外部产物
|
||||||
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html
|
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html
|
||||||
COPY --from=backend-build /app/backend/build/libs/*.jar app.jar
|
COPY --from=backend-build /app/backend/build/libs/*.jar app.jar
|
||||||
|
|
||||||
# 复制 Nginx 配置
|
# 复制 Nginx 配置
|
||||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
# 创建启动脚本
|
# 创建更新服务相关目录和脚本
|
||||||
|
RUN mkdir -p /app/updates /app/backups /var/log/polyhermes
|
||||||
|
COPY docker/update-service.py /app/update-service.py
|
||||||
COPY docker/start.sh /app/start.sh
|
COPY docker/start.sh /app/start.sh
|
||||||
RUN chmod +x /app/start.sh
|
RUN chmod +x /app/start.sh
|
||||||
|
|
||||||
# 创建非 root 用户(用于运行后端应用)
|
# 记录初始版本(从构建参数)
|
||||||
|
ARG VERSION=dev
|
||||||
|
ARG GIT_TAG=dev
|
||||||
|
RUN echo "{\"version\":\"${VERSION}\",\"tag\":\"${GIT_TAG}\",\"buildTime\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > /app/version.json
|
||||||
|
|
||||||
|
# 创建非 root 用户
|
||||||
RUN useradd -m -u 1000 appuser
|
RUN useradd -m -u 1000 appuser
|
||||||
|
|
||||||
# 设置目录权限(Nginx 以 root 运行,后端应用以 appuser 运行)
|
# 设置目录权限
|
||||||
RUN mkdir -p /var/log/nginx /var/lib/nginx /var/cache/nginx /var/run && \
|
RUN mkdir -p /var/log/nginx /var/lib/nginx /var/cache/nginx /var/run && \
|
||||||
chown -R appuser:appuser /app && \
|
chown -R appuser:appuser /app && \
|
||||||
chown -R root:root /usr/share/nginx/html && \
|
chown -R root:root /usr/share/nginx/html /var/log/nginx /var/lib/nginx /var/cache/nginx /etc/nginx /var/run
|
||||||
chown -R root:root /var/log/nginx && \
|
|
||||||
chown -R root:root /var/lib/nginx && \
|
|
||||||
chown -R root:root /var/cache/nginx && \
|
|
||||||
chown -R root:root /etc/nginx && \
|
|
||||||
chown -R root:root /var/run
|
|
||||||
|
|
||||||
# 保持 root 用户(Nginx 需要 root 权限绑定 80 端口)
|
|
||||||
# USER appuser
|
|
||||||
|
|
||||||
# 暴露端口
|
# 暴露端口
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# 健康检查
|
# 健康检查
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||||
CMD curl -f http://localhost/api/health || exit 1
|
CMD curl -f http://localhost/api/system/health || exit 1
|
||||||
|
|
||||||
# 启动服务(同时启动 Nginx 和后端)
|
# 启动服务
|
||||||
ENTRYPOINT ["/app/start.sh"]
|
ENTRYPOINT ["/app/start.sh"]
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
[](https://github.com/WrBug/PolyHermes)
|
[](https://github.com/WrBug/PolyHermes)
|
||||||
[](https://x.com/polyhermes)
|
[](https://x.com/polyhermes)
|
||||||
|
[](https://hub.docker.com/r/wrbug/polyhermes)
|
||||||
|
|
||||||
> 🌐 **Language**: [English](README_EN.md) | 中文
|
> 🌐 **Language**: [English](README_EN.md) | 中文
|
||||||
|
|
||||||
@@ -115,6 +116,7 @@
|
|||||||
- **API 健康检查**:实时监控 Polymarket API 的健康状态
|
- **API 健康检查**:实时监控 Polymarket API 的健康状态
|
||||||
- **用户管理**:管理系统用户,支持添加、编辑、删除用户
|
- **用户管理**:管理系统用户,支持添加、编辑、删除用户
|
||||||
- **公告管理**:查看系统公告和更新信息
|
- **公告管理**:查看系统公告和更新信息
|
||||||
|
- **动态更新**:支持在线更新系统版本,无需重新部署容器
|
||||||
|
|
||||||
### 🚀 技术特性
|
### 🚀 技术特性
|
||||||
|
|
||||||
@@ -163,6 +165,29 @@
|
|||||||
|
|
||||||
**部署步骤**:
|
**部署步骤**:
|
||||||
|
|
||||||
|
#### ⚡ 一键安装(最快,推荐新用户)
|
||||||
|
|
||||||
|
**使用 curl(推荐):**
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
**使用 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
|
||||||
|
```
|
||||||
|
|
||||||
|
这个命令会自动:
|
||||||
|
- 📁 创建专用工作目录 `~/polyhermes`
|
||||||
|
- ✅ 自动检查 Docker 环境
|
||||||
|
- ⚙️ 交互式配置所有参数(支持回车使用默认值)
|
||||||
|
- 🔐 自动生成安全的随机密钥
|
||||||
|
- 🚀 自动下载最新镜像并部署
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 📋 其他部署方式
|
||||||
|
|
||||||
1. **使用 Docker Hub 镜像(推荐,生产环境首选)**
|
1. **使用 Docker Hub 镜像(推荐,生产环境首选)**
|
||||||
|
|
||||||
**方式 1:独立部署(无需 clone 代码,推荐)**
|
**方式 1:独立部署(无需 clone 代码,推荐)**
|
||||||
@@ -228,6 +253,25 @@ docker-compose up -d
|
|||||||
# image: wrbug/polyhermes:v1.0.0
|
# image: wrbug/polyhermes:v1.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**更新 Docker 版本**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 停止当前容器
|
||||||
|
docker-compose -f docker-compose.prod.yml down
|
||||||
|
|
||||||
|
# 2. 拉取最新镜像
|
||||||
|
docker pull wrbug/polyhermes:latest
|
||||||
|
|
||||||
|
# 3. 重新启动服务
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
# 或更新到特定版本(例如 v1.0.1)
|
||||||
|
# 修改 docker-compose.prod.yml 中的镜像标签为: image: wrbug/polyhermes:v1.0.1
|
||||||
|
# 然后执行: docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
详细更新说明请参考:[部署文档 - 更新 Docker 版本](docs/zh/DEPLOYMENT.md#更新-docker-版本)
|
||||||
|
|
||||||
2. **本地构建部署(开发环境)**
|
2. **本地构建部署(开发环境)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -251,7 +295,6 @@ DB_USERNAME=root
|
|||||||
DB_PASSWORD=your_password_here
|
DB_PASSWORD=your_password_here
|
||||||
SPRING_PROFILES_ACTIVE=prod
|
SPRING_PROFILES_ACTIVE=prod
|
||||||
SERVER_PORT=80
|
SERVER_PORT=80
|
||||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
|
||||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||||
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
||||||
EOF
|
EOF
|
||||||
@@ -318,7 +361,6 @@ cd frontend
|
|||||||
| `DB_USERNAME` | 数据库用户名 | `root` |
|
| `DB_USERNAME` | 数据库用户名 | `root` |
|
||||||
| `DB_PASSWORD` | 数据库密码 | - |
|
| `DB_PASSWORD` | 数据库密码 | - |
|
||||||
| `SERVER_PORT` | 后端服务端口 | `8000` |
|
| `SERVER_PORT` | 后端服务端口 | `8000` |
|
||||||
| `POLYGON_RPC_URL` | Polygon RPC 地址 | `https://polygon-rpc.com` |
|
|
||||||
| `JWT_SECRET` | JWT 密钥 | - |
|
| `JWT_SECRET` | JWT 密钥 | - |
|
||||||
| `ADMIN_RESET_PASSWORD_KEY` | 管理员密码重置密钥 | - |
|
| `ADMIN_RESET_PASSWORD_KEY` | 管理员密码重置密钥 | - |
|
||||||
| `CRYPTO_SECRET_KEY` | 加密密钥(用于加密存储私钥和 API Key) | - |
|
| `CRYPTO_SECRET_KEY` | 加密密钥(用于加密存储私钥和 API Key) | - |
|
||||||
@@ -379,6 +421,7 @@ cd frontend
|
|||||||
- [开发文档](docs/zh/DEVELOPMENT.md) - 开发指南
|
- [开发文档](docs/zh/DEVELOPMENT.md) - 开发指南
|
||||||
- [跟单系统需求文档](docs/zh/copy-trading-requirements.md) - 后端 API 接口文档
|
- [跟单系统需求文档](docs/zh/copy-trading-requirements.md) - 后端 API 接口文档
|
||||||
- [前端需求文档](docs/zh/copy-trading-frontend-requirements.md) - 前端功能文档
|
- [前端需求文档](docs/zh/copy-trading-frontend-requirements.md) - 前端功能文档
|
||||||
|
- [动态更新文档](docs/zh/DYNAMIC_UPDATE.md) - 动态更新功能说明
|
||||||
|
|
||||||
### 🤝 贡献指南
|
### 🤝 贡献指南
|
||||||
|
|
||||||
@@ -415,6 +458,7 @@ cd frontend
|
|||||||
|
|
||||||
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
|
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
|
||||||
- [Twitter](https://x.com/polyhermes)
|
- [Twitter](https://x.com/polyhermes)
|
||||||
|
- [Telegram 群组](https://t.me/polyhermes)
|
||||||
- [Polymarket 官网](https://polymarket.com)
|
- [Polymarket 官网](https://polymarket.com)
|
||||||
- [Polymarket API 文档](https://docs.polymarket.com)
|
- [Polymarket API 文档](https://docs.polymarket.com)
|
||||||
|
|
||||||
|
|||||||
+44
-2
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
[](https://github.com/WrBug/PolyHermes)
|
[](https://github.com/WrBug/PolyHermes)
|
||||||
[](https://x.com/polyhermes)
|
[](https://x.com/polyhermes)
|
||||||
|
[](https://hub.docker.com/r/wrbug/polyhermes)
|
||||||
|
|
||||||
> 🌐 **Language**: English | [中文](README.md)
|
> 🌐 **Language**: English | [中文](README.md)
|
||||||
|
|
||||||
@@ -163,6 +164,29 @@ Deploy both frontend and backend together in a single Docker container, using Ng
|
|||||||
|
|
||||||
**Deployment Steps**:
|
**Deployment Steps**:
|
||||||
|
|
||||||
|
#### ⚡ One-Liner Installation (Fastest, Recommended for New Users)
|
||||||
|
|
||||||
|
**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 dedicated working directory `~/polyhermes`
|
||||||
|
- ✅ Automatically check Docker environment
|
||||||
|
- ⚙️ Interactive configuration for all parameters (supports pressing Enter for defaults)
|
||||||
|
- 🔐 Automatically generate secure random keys
|
||||||
|
- 🚀 Automatically download latest images and deploy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 📋 Alternative Deployment Methods
|
||||||
|
|
||||||
1. **Using Docker Hub Images (Recommended, Production First Choice)**
|
1. **Using Docker Hub Images (Recommended, Production First Choice)**
|
||||||
|
|
||||||
**Method 1: Standalone Deployment (No code clone required, Recommended)**
|
**Method 1: Standalone Deployment (No code clone required, Recommended)**
|
||||||
@@ -228,6 +252,25 @@ docker-compose up -d
|
|||||||
# image: wrbug/polyhermes:v1.0.0
|
# image: wrbug/polyhermes:v1.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Update Docker Version**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Stop current containers
|
||||||
|
docker-compose -f docker-compose.prod.yml down
|
||||||
|
|
||||||
|
# 2. Pull latest image
|
||||||
|
docker pull wrbug/polyhermes:latest
|
||||||
|
|
||||||
|
# 3. Restart services
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
# Or update to specific version (e.g., v1.0.1)
|
||||||
|
# Modify image tag in docker-compose.prod.yml to: image: wrbug/polyhermes:v1.0.1
|
||||||
|
# Then run: docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
For detailed update instructions, please refer to: [Deployment Guide - Update Docker Version](docs/en/DEPLOYMENT.md#update-docker-version)
|
||||||
|
|
||||||
2. **Local Build Deployment (Development Environment)**
|
2. **Local Build Deployment (Development Environment)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -251,7 +294,6 @@ DB_USERNAME=root
|
|||||||
DB_PASSWORD=your_password_here
|
DB_PASSWORD=your_password_here
|
||||||
SPRING_PROFILES_ACTIVE=prod
|
SPRING_PROFILES_ACTIVE=prod
|
||||||
SERVER_PORT=80
|
SERVER_PORT=80
|
||||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
|
||||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||||
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
||||||
EOF
|
EOF
|
||||||
@@ -318,7 +360,6 @@ cd frontend
|
|||||||
| `DB_USERNAME` | Database username | `root` |
|
| `DB_USERNAME` | Database username | `root` |
|
||||||
| `DB_PASSWORD` | Database password | - |
|
| `DB_PASSWORD` | Database password | - |
|
||||||
| `SERVER_PORT` | Backend service port | `8000` |
|
| `SERVER_PORT` | Backend service port | `8000` |
|
||||||
| `POLYGON_RPC_URL` | Polygon RPC address | `https://polygon-rpc.com` |
|
|
||||||
| `JWT_SECRET` | JWT secret key | - |
|
| `JWT_SECRET` | JWT secret key | - |
|
||||||
| `ADMIN_RESET_PASSWORD_KEY` | Admin password reset key | - |
|
| `ADMIN_RESET_PASSWORD_KEY` | Admin password reset key | - |
|
||||||
| `CRYPTO_SECRET_KEY` | Encryption key (for encrypting stored private keys and API Keys) | - |
|
| `CRYPTO_SECRET_KEY` | Encryption key (for encrypting stored private keys and API Keys) | - |
|
||||||
@@ -415,6 +456,7 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
|
|||||||
|
|
||||||
- [GitHub Repository](https://github.com/WrBug/PolyHermes)
|
- [GitHub Repository](https://github.com/WrBug/PolyHermes)
|
||||||
- [Twitter](https://x.com/polyhermes)
|
- [Twitter](https://x.com/polyhermes)
|
||||||
|
- [Telegram Group](https://t.me/polyhermes)
|
||||||
- [Polymarket Official Website](https://polymarket.com)
|
- [Polymarket Official Website](https://polymarket.com)
|
||||||
- [Polymarket API Documentation](https://docs.polymarket.com)
|
- [Polymarket API Documentation](https://docs.polymarket.com)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
## 版本信息
|
||||||
|
- **版本号**: v1.1.15
|
||||||
|
- **发布日期**: 2026-01-19
|
||||||
|
- **基础版本**: v1.1.14
|
||||||
|
|
||||||
|
## 改动摘要
|
||||||
|
本次版本优化了订单详情处理逻辑,提升了系统稳定性和开发体验。
|
||||||
|
|
||||||
|
## 主要改动
|
||||||
|
|
||||||
|
### 🐛 Bug 修复
|
||||||
|
|
||||||
|
#### 1. 优化订单详情为 null 时的处理逻辑
|
||||||
|
- **提交**: 7385eff
|
||||||
|
- **文件**: OrderStatusUpdateService.kt
|
||||||
|
- **问题**:
|
||||||
|
- 订单详情为 null 且已发送通知超过 60 秒时,订单被直接删除
|
||||||
|
- 导致已经正确处理并发送 TG 通知的订单被意外删除
|
||||||
|
- **修复**:
|
||||||
|
- 当订单详情为 null 且 notificationSent = true 超过 60 秒时,将订单状态改为 fully_matched
|
||||||
|
- fully_matched 状态的订单会被自动过滤,不再查询详情
|
||||||
|
- 避免已处理的订单被误删除
|
||||||
|
|
||||||
|
### 🧹 日志清理
|
||||||
|
|
||||||
|
#### 2. 清理 MarketPollingService 中多余的 debug 日志
|
||||||
|
- **提交**: d768da7, 07b4d65
|
||||||
|
- **文件**: MarketPollingService.kt
|
||||||
|
- **改进**:
|
||||||
|
- 删除多余的 debug 日志输出
|
||||||
|
- 减少冗余日志,提升日志可读性
|
||||||
|
- 优化性能(减少日志 I/O)
|
||||||
|
|
||||||
|
### ✨ 新增功能
|
||||||
|
|
||||||
|
#### 3. 添加订单详情查询工具脚本
|
||||||
|
- **提交**: b658270
|
||||||
|
- **新增文件**:
|
||||||
|
- scripts/get-order-detail.js - 订单详情查询脚本
|
||||||
|
- scripts/package.json - 依赖配置文件
|
||||||
|
- **功能**:
|
||||||
|
- 快速查询 Polymarket 订单详情
|
||||||
|
- 支持自动创建 API Key
|
||||||
|
- 完善的错误处理和参数验证
|
||||||
|
- 详细的订单信息输出
|
||||||
|
|
||||||
|
## 文件变更统计
|
||||||
|
- **修改文件数**: 3
|
||||||
|
- **新增文件数**: 2
|
||||||
|
- **新增行数**: 192
|
||||||
|
- **删除行数**: 6
|
||||||
|
|
||||||
|
## 技术细节
|
||||||
|
|
||||||
|
### 订单状态管理优化
|
||||||
|
- 使用 fully_matched 状态标记已处理订单
|
||||||
|
- 通过数据库查询条件自动过滤,无需额外缓存
|
||||||
|
- 保持数据一致性和可追溯性
|
||||||
|
|
||||||
|
### 工具脚本特性
|
||||||
|
- 基于 Polymarket CLOB Client v5.2.1
|
||||||
|
- 支持 derive/create API Key 自动处理
|
||||||
|
- 完整的参数验证和错误提示
|
||||||
|
- 友好的命令行交互体验
|
||||||
|
|
||||||
|
## 升级建议
|
||||||
|
- 无需特殊操作,直接部署即可
|
||||||
|
- 建议验证订单处理逻辑是否正常工作
|
||||||
|
- 可以使用新增的工具脚本进行调试
|
||||||
|
|
||||||
|
## 完整提交列表
|
||||||
|
- 7385eff - 优化订单详情为null时的处理逻辑
|
||||||
|
- d768da7 - 清理 MarketPollingService 中多余的 debug 日志
|
||||||
|
- b658270 - 添加订单详情查询脚本
|
||||||
|
- 07b4d65 - 清理 MarketPollingService 调试日志
|
||||||
|
|
||||||
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# v1.0.1
|
|
||||||
|
|
||||||
## 🚀 性能优化
|
|
||||||
|
|
||||||
### 📊 订单簿请求优化
|
|
||||||
* 优化订单簿获取逻辑,仅在需要时请求,避免不必要的 API 调用
|
|
||||||
* 当未配置需要订单簿的过滤条件(价差、订单深度)时,跳过订单簿获取
|
|
||||||
* 订单簿只请求一次,在过滤检查中复用,提高性能
|
|
||||||
|
|
||||||
### 💰 卖出价格计算优化
|
|
||||||
* 卖出改为市价卖出:优先使用订单簿的 bestBid(最高买入价)
|
|
||||||
* 如果 bestBid 获取失败,自动降级使用 Leader 的交易价格
|
|
||||||
* 卖出价格固定按 90% 计算,不再使用价格容忍度配置
|
|
||||||
* 提高卖出订单的成交率
|
|
||||||
|
|
||||||
### ⚙️ 价格容忍度默认值
|
|
||||||
* 如果价格容忍度配置为 0,自动使用默认值 5%
|
|
||||||
* 确保买入订单能够正常应用价格调整,提高成交率
|
|
||||||
|
|
||||||
## 🔧 代码优化
|
|
||||||
|
|
||||||
### 🎯 使用枚举优化过滤逻辑
|
|
||||||
* 新增 `FilterResult` 数据类和 `FilterStatus` 枚举
|
|
||||||
* 使用类型安全的枚举替代字符串判断,提高代码可维护性
|
|
||||||
* 移除无用字段 `isBuyOrder` 参数
|
|
||||||
* 优化 `checkFilters` 方法返回值,使用数据类封装结果
|
|
||||||
|
|
||||||
### 📝 代码结构优化
|
|
||||||
* 减少 if-else 嵌套,使用 Kotlin 链式调用和空安全操作符
|
|
||||||
* 优化卖出价格计算逻辑,使用 `runCatching` 和 `?.let` 简化代码
|
|
||||||
* 提取公共方法 `calculateFallbackSellPrice`,提高代码复用性
|
|
||||||
|
|
||||||
## 🗑️ 功能移除
|
|
||||||
|
|
||||||
### 移除最小订单簿深度功能
|
|
||||||
* 移除 `minOrderbookDepth`(最小订单簿深度)配置项
|
|
||||||
* 简化过滤逻辑,只保留 `minOrderDepth`(最小订单深度)
|
|
||||||
* 更新前端界面,移除相关配置项和提示文案
|
|
||||||
|
|
||||||
### 优化最小订单深度逻辑
|
|
||||||
* 修改 `minOrderDepth` 检查逻辑,检查所有方向(买盘+卖盘)的总深度
|
|
||||||
* 不再区分买卖方向,提供更全面的市场流动性评估
|
|
||||||
|
|
||||||
## 🐛 Bug 修复
|
|
||||||
|
|
||||||
* 修复盈亏和持仓价值计算逻辑未实现的问题
|
|
||||||
* 修复过滤检查中订单簿可能重复请求的问题
|
|
||||||
|
|
||||||
## 📚 文档更新
|
|
||||||
|
|
||||||
* 更新前端多语言文案,优化提示信息
|
|
||||||
* 移除最小订单簿深度相关的文档说明
|
|
||||||
|
|
||||||
**Full Changelog**: https://github.com/WrBug/polymarket-bot/compare/v1.0.0...v1.0.1
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -130,7 +130,7 @@ export PROXY_PORT=8888
|
|||||||
- 代理配置错误
|
- 代理配置错误
|
||||||
|
|
||||||
**排查步骤**:
|
**排查步骤**:
|
||||||
1. 检查 `polymarket.rtds.ws-url` 配置是否正确
|
1. 检查 Polymarket RTDS WebSocket URL(现在使用代码常量 `PolymarketConstants.RTDS_WS_URL`)
|
||||||
2. 检查网络连接
|
2. 检查网络连接
|
||||||
3. 查看详细错误日志
|
3. 查看详细错误日志
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ dependencies {
|
|||||||
// Logging
|
// Logging
|
||||||
implementation("org.slf4j:slf4j-api")
|
implementation("org.slf4j:slf4j-api")
|
||||||
|
|
||||||
|
// Caffeine Cache (LRU)
|
||||||
|
implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
|
||||||
|
|
||||||
// Test
|
// Test
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test")
|
||||||
|
|||||||
@@ -154,9 +154,6 @@ SPRING_PROFILES_ACTIVE=prod
|
|||||||
# 服务器端口
|
# 服务器端口
|
||||||
SERVER_PORT=8000
|
SERVER_PORT=8000
|
||||||
|
|
||||||
# Polygon RPC
|
|
||||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
|
||||||
|
|
||||||
# JWT 密钥(已自动生成随机值,生产环境建议修改)
|
# JWT 密钥(已自动生成随机值,生产环境建议修改)
|
||||||
JWT_SECRET=${JWT_SECRET}
|
JWT_SECRET=${JWT_SECRET}
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${SERVER_PORT:-8000}:8000"
|
- "${SERVER_PORT:-8000}:8000"
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
|
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
|
||||||
- DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
|
- DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
|
||||||
- DB_USERNAME=${DB_USERNAME:-root}
|
- DB_USERNAME=${DB_USERNAME:-root}
|
||||||
- DB_PASSWORD=${DB_PASSWORD:-}
|
- DB_PASSWORD=${DB_PASSWORD:-}
|
||||||
- SERVER_PORT=8000
|
- SERVER_PORT=8000
|
||||||
- POLYGON_RPC_URL=${POLYGON_RPC_URL:-https://polygon-rpc.com}
|
|
||||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||||
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
||||||
|
volumes:
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -30,12 +32,14 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${MYSQL_PORT:-3306}:3306"
|
- "${MYSQL_PORT:-3306}:3306"
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
|
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
|
||||||
- MYSQL_DATABASE=polyhermes
|
- MYSQL_DATABASE=polyhermes
|
||||||
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
||||||
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
||||||
volumes:
|
volumes:
|
||||||
- mysql-data:/var/lib/mysql
|
- mysql-data:/var/lib/mysql
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
Executable
+59
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 清理 Flyway V29 失败记录的脚本
|
||||||
|
|
||||||
|
echo "=== 清理 Flyway V29 失败记录 ==="
|
||||||
|
echo ""
|
||||||
|
echo "请确保 MySQL 正在运行,然后输入数据库密码"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 数据库配置
|
||||||
|
DB_HOST="localhost"
|
||||||
|
DB_PORT="3306"
|
||||||
|
DB_NAME="polymarket_bot"
|
||||||
|
DB_USER="root"
|
||||||
|
|
||||||
|
# 检查 MySQL 命令是否可用
|
||||||
|
if ! command -v mysql &> /dev/null; then
|
||||||
|
echo "❌ 错误: 未找到 mysql 命令"
|
||||||
|
echo ""
|
||||||
|
echo "请使用数据库客户端(如 Navicat、DataGrip 等)执行以下 SQL:"
|
||||||
|
echo ""
|
||||||
|
echo "-- 1. 查看 Flyway 历史记录"
|
||||||
|
echo "SELECT version, description, installed_on, success "
|
||||||
|
echo "FROM flyway_schema_history "
|
||||||
|
echo "WHERE version >= 28"
|
||||||
|
echo "ORDER BY installed_rank;"
|
||||||
|
echo ""
|
||||||
|
echo "-- 2. 删除 V29 的失败记录"
|
||||||
|
echo "DELETE FROM flyway_schema_history WHERE version = '29';"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 执行清理
|
||||||
|
echo "正在连接数据库..."
|
||||||
|
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p "$DB_NAME" << 'EOF'
|
||||||
|
-- 查看当前状态
|
||||||
|
SELECT '=== 当前 Flyway 历史记录 ===' as '';
|
||||||
|
SELECT version, description, installed_on, success
|
||||||
|
FROM flyway_schema_history
|
||||||
|
WHERE version >= 28
|
||||||
|
ORDER BY installed_rank;
|
||||||
|
|
||||||
|
-- 删除 V29 失败记录
|
||||||
|
SELECT '=== 删除 V29 记录 ===' as '';
|
||||||
|
DELETE FROM flyway_schema_history WHERE version = '29';
|
||||||
|
|
||||||
|
-- 确认删除结果
|
||||||
|
SELECT CONCAT('已删除 ', ROW_COUNT(), ' 条记录') as result;
|
||||||
|
|
||||||
|
-- 再次查看状态
|
||||||
|
SELECT '=== 清理后的 Flyway 历史记录 ===' as '';
|
||||||
|
SELECT version, description, installed_on, success
|
||||||
|
FROM flyway_schema_history
|
||||||
|
WHERE version >= 28
|
||||||
|
ORDER BY installed_rank;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ 清理完成!现在可以重启应用了"
|
||||||
BIN
Binary file not shown.
@@ -2,8 +2,10 @@ package com.wrbug.polymarketbot
|
|||||||
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
class PolymarketBotApplication
|
class PolymarketBotApplication
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.wrbug.polymarketbot.api
|
||||||
|
|
||||||
|
import retrofit2.Call
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 币安现货公开 API(K 线等)
|
||||||
|
* Base URL: https://api.binance.com
|
||||||
|
* 文档: https://developers.binance.com/docs/binance-spot-api-docs/rest-api
|
||||||
|
*/
|
||||||
|
interface BinanceApi {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* K 线数据
|
||||||
|
* 返回每根 K 线: [openTime, open, high, low, close, volume, closeTime, ...]
|
||||||
|
*/
|
||||||
|
@GET("/api/v3/klines")
|
||||||
|
fun getKlines(
|
||||||
|
@Query("symbol") symbol: String,
|
||||||
|
@Query("interval") interval: String,
|
||||||
|
@Query("limit") limit: Int = 30,
|
||||||
|
@Query("startTime") startTime: Long? = null,
|
||||||
|
@Query("endTime") endTime: Long? = null
|
||||||
|
): Call<List<List<Any>>>
|
||||||
|
}
|
||||||
@@ -41,6 +41,17 @@ interface BuilderRelayerApi {
|
|||||||
@Query("address") address: String,
|
@Query("address") address: String,
|
||||||
@Query("type") type: String
|
@Query("type") type: String
|
||||||
): Response<NoncePayload>
|
): Response<NoncePayload>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Relay Payload(PROXY 类型执行时使用)
|
||||||
|
* GET /relay-payload?address={address}&type=PROXY
|
||||||
|
* 参考: builder-relayer-client endpoints GET_RELAY_PAYLOAD
|
||||||
|
*/
|
||||||
|
@GET("/relay-payload")
|
||||||
|
suspend fun getRelayPayload(
|
||||||
|
@Query("address") address: String,
|
||||||
|
@Query("type") type: String
|
||||||
|
): Response<RelayPayload>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取交易状态
|
* 获取交易状态
|
||||||
@@ -81,7 +92,7 @@ interface BuilderRelayerApi {
|
|||||||
val data: String, // 调用数据(十六进制字符串,带 0x 前缀)
|
val data: String, // 调用数据(十六进制字符串,带 0x 前缀)
|
||||||
|
|
||||||
@SerializedName("nonce")
|
@SerializedName("nonce")
|
||||||
val nonce: String, // Safe nonce(字符串)
|
val nonce: String? = null, // Safe nonce(SAFE 必填,SAFE-CREATE 不传)
|
||||||
|
|
||||||
@SerializedName("signature")
|
@SerializedName("signature")
|
||||||
val signature: String, // Safe 签名(packed signature,十六进制字符串,带 0x 前缀)
|
val signature: String, // Safe 签名(packed signature,十六进制字符串,带 0x 前缀)
|
||||||
@@ -96,6 +107,7 @@ interface BuilderRelayerApi {
|
|||||||
/**
|
/**
|
||||||
* 签名参数
|
* 签名参数
|
||||||
* 参考: builder-relayer-client/src/types.ts 的 SignatureParams
|
* 参考: builder-relayer-client/src/types.ts 的 SignatureParams
|
||||||
|
* Safe 使用 operation/safeTxnGas/baseGas 等,PROXY 使用 relayHub/relay/relayerFee 等
|
||||||
*/
|
*/
|
||||||
data class SignatureParams(
|
data class SignatureParams(
|
||||||
@SerializedName("gasPrice")
|
@SerializedName("gasPrice")
|
||||||
@@ -114,7 +126,29 @@ interface BuilderRelayerApi {
|
|||||||
val gasToken: String? = null,
|
val gasToken: String? = null,
|
||||||
|
|
||||||
@SerializedName("refundReceiver")
|
@SerializedName("refundReceiver")
|
||||||
val refundReceiver: String? = null
|
val refundReceiver: String? = null,
|
||||||
|
|
||||||
|
@SerializedName("relayerFee")
|
||||||
|
val relayerFee: String? = null,
|
||||||
|
|
||||||
|
@SerializedName("gasLimit")
|
||||||
|
val gasLimit: String? = null,
|
||||||
|
|
||||||
|
@SerializedName("relayHub")
|
||||||
|
val relayHub: String? = null,
|
||||||
|
|
||||||
|
@SerializedName("relay")
|
||||||
|
val relay: String? = null,
|
||||||
|
|
||||||
|
/** SAFE-CREATE 签名参数 */
|
||||||
|
@SerializedName("paymentToken")
|
||||||
|
val paymentToken: String? = null,
|
||||||
|
|
||||||
|
@SerializedName("payment")
|
||||||
|
val payment: String? = null,
|
||||||
|
|
||||||
|
@SerializedName("paymentReceiver")
|
||||||
|
val paymentReceiver: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,6 +176,17 @@ interface BuilderRelayerApi {
|
|||||||
@SerializedName("nonce")
|
@SerializedName("nonce")
|
||||||
val nonce: String
|
val nonce: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay Payload(PROXY 执行时获取 relay 地址与 nonce)
|
||||||
|
* 参考: builder-relayer-client types RelayPayload
|
||||||
|
*/
|
||||||
|
data class RelayPayload(
|
||||||
|
@SerializedName("address")
|
||||||
|
val address: String,
|
||||||
|
@SerializedName("nonce")
|
||||||
|
val nonce: String
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relayer 交易详情
|
* Relayer 交易详情
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.wrbug.polymarketbot.api
|
package com.wrbug.polymarketbot.api
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
@@ -29,10 +30,11 @@ data class JsonRpcRequest(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON-RPC 响应
|
* JSON-RPC 响应
|
||||||
|
* 使用 JsonElement 类型处理 result 字段,可以灵活处理字符串、对象、数组等类型
|
||||||
*/
|
*/
|
||||||
data class JsonRpcResponse(
|
data class JsonRpcResponse(
|
||||||
val jsonrpc: String? = null,
|
val jsonrpc: String? = null,
|
||||||
val result: String? = null,
|
val result: JsonElement? = null, // 使用 JsonElement 类型,可以处理任意 JSON 类型
|
||||||
val error: JsonRpcError? = null,
|
val error: JsonRpcError? = null,
|
||||||
val id: Int? = null
|
val id: Int? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.wrbug.polymarketbot.api
|
package com.wrbug.polymarketbot.api
|
||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import okhttp3.ResponseBody
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.*
|
import retrofit2.http.*
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ interface PolymarketClobApi {
|
|||||||
*/
|
*/
|
||||||
@POST("/orders/batch")
|
@POST("/orders/batch")
|
||||||
suspend fun createOrdersBatch(
|
suspend fun createOrdersBatch(
|
||||||
@Body request: CreateOrdersBatchRequest
|
@Body request: List<NewOrderRequest>
|
||||||
): Response<List<OrderResponse>>
|
): Response<List<OrderResponse>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,33 +150,49 @@ interface PolymarketClobApi {
|
|||||||
@GET("/auth/derive-api-key")
|
@GET("/auth/derive-api-key")
|
||||||
suspend fun deriveApiKey(): Response<ApiKeyResponse>
|
suspend fun deriveApiKey(): Response<ApiKeyResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取费率
|
||||||
|
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
|
||||||
|
* 端点: GET /fee-rate
|
||||||
|
*
|
||||||
|
* @param tokenId Token ID
|
||||||
|
* @return 费率响应
|
||||||
|
*/
|
||||||
|
@GET("/fee-rate")
|
||||||
|
suspend fun getFeeRate(
|
||||||
|
@Query("token_id") tokenId: String
|
||||||
|
): Response<FeeRateResponse>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取服务器时间
|
* 获取服务器时间
|
||||||
* 端点: /time
|
* 端点: /time 返回纯数字(Unix 时间戳),非 JSON
|
||||||
*/
|
*/
|
||||||
@GET("/time")
|
@GET("/time")
|
||||||
suspend fun getServerTime(): Response<ServerTimeResponse>
|
suspend fun getServerTime(): Response<ResponseBody>
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求和响应数据类
|
// 请求和响应数据类
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 签名的订单对象(根据官方文档)
|
* V2 签名的订单对象
|
||||||
* 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order
|
* EIP-712 签名字段: salt, maker, signer, tokenId, makerAmount, takerAmount, side, signatureType, timestamp, metadata, builder
|
||||||
|
* API payload 额外字段: taker, expiration (不在 EIP-712 签名中,但 API 请求需要)
|
||||||
|
* 参考: clob-client-v2/src/types/ordersV2.ts NewOrderV2
|
||||||
*/
|
*/
|
||||||
data class SignedOrderObject(
|
data class SignedOrderObject(
|
||||||
val salt: Long, // random salt used to create unique order
|
val salt: Long, // random salt used to create unique order
|
||||||
val maker: String, // maker address (funder)
|
val maker: String, // maker address (funder)
|
||||||
val signer: String, // signing address
|
val signer: String, // signing address
|
||||||
val taker: String, // taker address (operator)
|
val taker: String, // taker address (zero address for public orders, NOT in EIP-712 signing)
|
||||||
val tokenId: String, // ERC1155 token ID of conditional token being traded
|
val tokenId: String, // ERC1155 token ID of conditional token being traded
|
||||||
val makerAmount: String, // maximum amount maker is willing to spend
|
val makerAmount: String, // maximum amount maker is willing to spend
|
||||||
val takerAmount: String, // minimum amount taker will pay the maker in return
|
val takerAmount: String, // minimum amount taker will pay the maker in return
|
||||||
val expiration: String, // unix expiration timestamp
|
|
||||||
val nonce: String, // maker's exchange nonce of the order is associated
|
|
||||||
val feeRateBps: String, // fee rate basis points as required by the operator
|
|
||||||
val side: String, // buy or sell enum index ("BUY" or "SELL")
|
val side: String, // buy or sell enum index ("BUY" or "SELL")
|
||||||
val signatureType: Int, // signature type enum index
|
val signatureType: Int, // signature type enum index
|
||||||
|
val timestamp: String, // order creation time in milliseconds (V2)
|
||||||
|
val expiration: String, // expiration timestamp unix seconds, "0" = no expiration (NOT in EIP-712 signing)
|
||||||
|
val metadata: String, // bytes32 metadata (V2)
|
||||||
|
val builder: String, // bytes32 builder code (V2)
|
||||||
val signature: String // hex encoded signature
|
val signature: String // hex encoded signature
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -184,10 +201,11 @@ data class SignedOrderObject(
|
|||||||
* 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order
|
* 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order
|
||||||
*/
|
*/
|
||||||
data class NewOrderRequest(
|
data class NewOrderRequest(
|
||||||
val order: SignedOrderObject, // signed object
|
val order: SignedOrderObject, // V2 signed object
|
||||||
val owner: String, // api key of order owner
|
val owner: String, // api key of order owner
|
||||||
val orderType: String, // order type ("FOK", "GTC", "GTD", "FAK")
|
val orderType: String, // order type ("FOK", "GTC", "GTD", "FAK")
|
||||||
val deferExec: Boolean = false // defer execution flag
|
val deferExec: Boolean = false, // defer execution
|
||||||
|
val postOnly: Boolean = false // post only (maker-only)
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -198,6 +216,7 @@ data class NewOrderResponse(
|
|||||||
val success: Boolean, // boolean indicating if server-side error
|
val success: Boolean, // boolean indicating if server-side error
|
||||||
@SerializedName("errorMsg")
|
@SerializedName("errorMsg")
|
||||||
val errorMsg: String? = null, // error message in case of unsuccessful placement
|
val errorMsg: String? = null, // error message in case of unsuccessful placement
|
||||||
|
val error: String? = null, // error message (alternative field, e.g. "Trading restricted in your region...")
|
||||||
@SerializedName("orderID")
|
@SerializedName("orderID")
|
||||||
val orderId: String? = null, // id of order(API 返回字段名为 orderID)
|
val orderId: String? = null, // id of order(API 返回字段名为 orderID)
|
||||||
@SerializedName("transactionsHashes")
|
@SerializedName("transactionsHashes")
|
||||||
@@ -208,27 +227,17 @@ data class NewOrderResponse(
|
|||||||
val takingAmount: String? = null, // taking amount
|
val takingAmount: String? = null, // taking amount
|
||||||
@SerializedName("makingAmount")
|
@SerializedName("makingAmount")
|
||||||
val makingAmount: String? = null // making amount
|
val makingAmount: String? = null // making amount
|
||||||
)
|
) {
|
||||||
|
/**
|
||||||
/**
|
* 获取错误信息的便捷方法
|
||||||
* 旧的订单请求格式(已废弃,保留用于兼容)
|
* 优先返回 errorMsg,其次返回 error,最后返回默认消息
|
||||||
* @deprecated 使用 NewOrderRequest 代替
|
*/
|
||||||
*/
|
fun getErrorMessage(): String {
|
||||||
@Deprecated("使用 NewOrderRequest 代替,需要签名的订单对象")
|
return errorMsg?.takeIf { it.isNotBlank() }
|
||||||
data class CreateOrderRequest(
|
?: error?.takeIf { it.isNotBlank() }
|
||||||
val market: String? = null, // condition ID(可选,如果提供tokenId则不需要)
|
?: "创建订单失败"
|
||||||
val token_id: String? = null, // token ID(可选,如果提供market则不需要)
|
}
|
||||||
val side: String, // "BUY" or "SELL"
|
}
|
||||||
val price: String,
|
|
||||||
val size: String,
|
|
||||||
val type: String = "LIMIT",
|
|
||||||
val expiration: Long? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Deprecated("使用 NewOrderRequest 代替")
|
|
||||||
data class CreateOrdersBatchRequest(
|
|
||||||
val orders: List<NewOrderRequest>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CancelOrdersBatchRequest(
|
data class CancelOrdersBatchRequest(
|
||||||
val orderIds: List<String>
|
val orderIds: List<String>
|
||||||
@@ -321,7 +330,8 @@ data class TradeResponse(
|
|||||||
val timestamp: String, // ISO 8601 格式字符串或时间戳
|
val timestamp: String, // ISO 8601 格式字符串或时间戳
|
||||||
val user: String?,
|
val user: String?,
|
||||||
val outcomeIndex: Int? = null, // 结果索引(0=YES, 1=NO)
|
val outcomeIndex: Int? = null, // 结果索引(0=YES, 1=NO)
|
||||||
val outcome: String? = null // 结果名称(如 "Up", "Down")
|
val outcome: String? = null, // 结果名称(如 "Up", "Down")
|
||||||
|
val tokenId: String? = null // CLOB tokenId(链上解析时从 ERC1155 取得,与 Gamma clobTokenIds 一致,用于下单)
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -351,10 +361,15 @@ data class ApiKeyResponse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 服务器时间响应
|
* 费率响应
|
||||||
|
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
|
||||||
|
*
|
||||||
|
* 注意:根据 TypeScript clob-client 源码,API 返回的字段名是 base_fee,而不是文档中的 fee_rate_bps
|
||||||
|
* 参考: clob-client/src/client.ts:312
|
||||||
*/
|
*/
|
||||||
data class ServerTimeResponse(
|
data class FeeRateResponse(
|
||||||
val timestamp: Long
|
@SerializedName("base_fee")
|
||||||
|
val baseFee: Int // 费率基点(0 表示无费率,1000 表示 10%)
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.api
|
|||||||
|
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,16 +17,68 @@ interface PolymarketGammaApi {
|
|||||||
* 根据 condition ID 列表获取市场信息
|
* 根据 condition ID 列表获取市场信息
|
||||||
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
|
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
|
||||||
* @param conditionIds condition ID 数组(16 进制字符串,如 "0x...")
|
* @param conditionIds condition ID 数组(16 进制字符串,如 "0x...")
|
||||||
|
* @param clobTokenIds CLOB token ID 数组(用于通过 tokenId 查询市场)
|
||||||
* @param includeTag 是否包含标签信息
|
* @param includeTag 是否包含标签信息
|
||||||
* @return 市场信息数组
|
* @return 市场信息数组
|
||||||
*/
|
*/
|
||||||
@GET("/markets")
|
@GET("/markets")
|
||||||
suspend fun listMarkets(
|
suspend fun listMarkets(
|
||||||
@Query("condition_ids") conditionIds: List<String>? = null,
|
@Query("condition_ids") conditionIds: List<String>? = null,
|
||||||
|
@Query("clob_token_ids") clobTokenIds: List<String>? = null,
|
||||||
@Query("include_tag") includeTag: Boolean? = null
|
@Query("include_tag") includeTag: Boolean? = null
|
||||||
): Response<List<MarketResponse>>
|
): Response<List<MarketResponse>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 slug 获取事件(用于 5/15 分钟加密市场)
|
||||||
|
* GET /events/slug/{slug},如 btc-updown-5m-1771007400
|
||||||
|
* 返回事件含 markets(conditionId、endDate、clobTokenIds 等)
|
||||||
|
*/
|
||||||
|
@GET("/events/slug/{slug}")
|
||||||
|
suspend fun getEventBySlug(@Path("slug") slug: String): Response<GammaEventBySlugResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gamma 按 slug 返回的事件结构
|
||||||
|
*/
|
||||||
|
data class GammaEventBySlugResponse(
|
||||||
|
val id: String? = null,
|
||||||
|
val slug: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val startDate: String? = null,
|
||||||
|
val endDate: String? = null,
|
||||||
|
val markets: List<GammaEventMarketItem>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事件下的市场项(5/15 分钟市场为二元,通常两个 outcome)
|
||||||
|
*/
|
||||||
|
data class GammaEventMarketItem(
|
||||||
|
val conditionId: String? = null,
|
||||||
|
val question: String? = null,
|
||||||
|
val endDate: String? = null,
|
||||||
|
val startDate: String? = null,
|
||||||
|
val clobTokenIds: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事件响应(从 MarketResponse.events 解析)
|
||||||
|
* Gamma API Event 含 negRisk,用于判断是否使用 Neg Risk Exchange 签约
|
||||||
|
*/
|
||||||
|
data class EventResponse(
|
||||||
|
val id: String? = null,
|
||||||
|
val ticker: String? = null,
|
||||||
|
val slug: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val category: String? = null,
|
||||||
|
val active: Boolean? = null,
|
||||||
|
val closed: Boolean? = null,
|
||||||
|
val archived: Boolean? = null,
|
||||||
|
val startDate: String? = null,
|
||||||
|
val endDate: String? = null,
|
||||||
|
val createdAt: String? = null,
|
||||||
|
val negRisk: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 市场响应(根据 Gamma API 文档)
|
* 市场响应(根据 Gamma API 文档)
|
||||||
*/
|
*/
|
||||||
@@ -51,6 +104,12 @@ data class MarketResponse(
|
|||||||
val liquidityNum: Double? = null,
|
val liquidityNum: Double? = null,
|
||||||
val lastTradePrice: Double? = null,
|
val lastTradePrice: Double? = null,
|
||||||
val bestBid: Double? = null,
|
val bestBid: Double? = null,
|
||||||
val bestAsk: Double? = null
|
val bestAsk: Double? = null,
|
||||||
|
val events: List<EventResponse>? = null, // 事件列表(从 events[0] 获取 slug)
|
||||||
|
// 以下字段可能存在于响应中,但不在标准文档中
|
||||||
|
val clobTokenIds: String? = null, // CLOB token IDs(可能是 JSON 字符串或数组)
|
||||||
|
val clob_token_ids: String? = null, // 下划线格式(兼容不同 API 版本)
|
||||||
|
val negRisk: Boolean? = null, // 事件级 neg risk(部分 API 直接返回在 market)
|
||||||
|
val negRiskOther: Boolean? = null // Market 级 neg risk 标记
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.wrbug.polymarketbot.config
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gson 配置类
|
||||||
|
* 统一配置 Gson 实例,使用 lenient 模式允许解析格式不严格的 JSON
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
class GsonConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Gson Bean
|
||||||
|
* 使用 lenient 模式,允许解析格式不严格的 JSON
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
fun gson(): Gson {
|
||||||
|
return GsonBuilder()
|
||||||
|
.setLenient()
|
||||||
|
.create()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+7
-8
@@ -36,20 +36,19 @@ class JwtAuthenticationInterceptor(
|
|||||||
handler: Any
|
handler: Any
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val path = request.requestURI
|
val path = request.requestURI
|
||||||
val method = request.method
|
|
||||||
|
// 只拦截 /api/** 路径
|
||||||
// 只拦截POST请求
|
if (!path.startsWith("/api/")) {
|
||||||
if (method != "POST") {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 排除不需要鉴权的路径
|
// 排除不需要鉴权的路径
|
||||||
if (excludePaths.contains(path)) {
|
if (excludePaths.contains(path)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只拦截 /api/** 路径
|
// 允许 OPTIONS 请求(CORS 预检请求)
|
||||||
if (!path.startsWith("/api/")) {
|
if (request.method == "OPTIONS") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.wrbug.polymarketbot.config
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.service.common.WebSocketSubscriptionService
|
||||||
|
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
|
||||||
|
import jakarta.annotation.PostConstruct
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控服务配置
|
||||||
|
* 处理 WebSocketSubscriptionService 和 CryptoTailMonitorService 之间的循环依赖
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
class MonitorServiceConfig(
|
||||||
|
private val webSocketSubscriptionService: WebSocketSubscriptionService,
|
||||||
|
private val cryptoTailMonitorService: CryptoTailMonitorService
|
||||||
|
) {
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
fun init() {
|
||||||
|
// 在所有 Bean 初始化后设置引用
|
||||||
|
webSocketSubscriptionService.setCryptoTailMonitorService(cryptoTailMonitorService)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
package com.wrbug.polymarketbot.config
|
package com.wrbug.polymarketbot.config
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
@@ -18,10 +19,9 @@ import retrofit2.converter.gson.GsonConverterFactory
|
|||||||
* - 账户 API Key 在调用时动态设置,不在此处配置
|
* - 账户 API Key 在调用时动态设置,不在此处配置
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
class RetrofitConfig {
|
class RetrofitConfig(
|
||||||
|
private val gson: Gson
|
||||||
@Value("\${polymarket.clob.base-url}")
|
) {
|
||||||
private lateinit var clobBaseUrl: String
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 CLOB API 客户端
|
* 创建 CLOB API 客户端
|
||||||
@@ -35,9 +35,9 @@ class RetrofitConfig {
|
|||||||
val okHttpClient = createClient().build()
|
val okHttpClient = createClient().build()
|
||||||
|
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
.create(PolymarketClobApi::class.java)
|
.create(PolymarketClobApi::class.java)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ class WebMvcConfig(
|
|||||||
// 再注册JWT认证拦截器
|
// 再注册JWT认证拦截器
|
||||||
registry.addInterceptor(jwtAuthenticationInterceptor)
|
registry.addInterceptor(jwtAuthenticationInterceptor)
|
||||||
.addPathPatterns("/api/**")
|
.addPathPatterns("/api/**")
|
||||||
registry.addInterceptor(jwtAuthenticationInterceptor)
|
|
||||||
.addPathPatterns("/api/**")
|
|
||||||
registry.addInterceptor(jwtAuthenticationInterceptor)
|
|
||||||
.addPathPatterns("/api/**")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-18
@@ -1,6 +1,7 @@
|
|||||||
package com.wrbug.polymarketbot.config
|
package com.wrbug.polymarketbot.config
|
||||||
|
|
||||||
import com.wrbug.polymarketbot.repository.UserRepository
|
import com.wrbug.polymarketbot.repository.UserRepository
|
||||||
|
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
|
||||||
import com.wrbug.polymarketbot.util.JwtUtils
|
import com.wrbug.polymarketbot.util.JwtUtils
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.http.server.ServerHttpRequest
|
import org.springframework.http.server.ServerHttpRequest
|
||||||
@@ -11,12 +12,13 @@ import org.springframework.web.socket.server.HandshakeInterceptor
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket 握手拦截器
|
* WebSocket 握手拦截器
|
||||||
* 用于验证 JWT token
|
* 优先使用短期票据验证,其次使用 JWT token
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
class WebSocketAuthInterceptor(
|
class WebSocketAuthInterceptor(
|
||||||
private val jwtUtils: JwtUtils,
|
private val jwtUtils: JwtUtils,
|
||||||
private val userRepository: UserRepository
|
private val userRepository: UserRepository,
|
||||||
|
private val webSocketTicketService: WebSocketTicketService
|
||||||
) : HandshakeInterceptor {
|
) : HandshakeInterceptor {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(WebSocketAuthInterceptor::class.java)
|
private val logger = LoggerFactory.getLogger(WebSocketAuthInterceptor::class.java)
|
||||||
@@ -27,22 +29,36 @@ class WebSocketAuthInterceptor(
|
|||||||
wsHandler: WebSocketHandler,
|
wsHandler: WebSocketHandler,
|
||||||
attributes: MutableMap<String, Any>
|
attributes: MutableMap<String, Any>
|
||||||
): Boolean {
|
): Boolean {
|
||||||
// 从查询参数或请求头获取 token
|
// 优先使用票据验证(推荐方式,不暴露 JWT)
|
||||||
val token = getTokenFromRequest(request)
|
val ticket = getTicketFromRequest(request)
|
||||||
|
if (ticket != null) {
|
||||||
if (token == null) {
|
val username = webSocketTicketService.validateAndConsumeTicket(ticket)
|
||||||
logger.warn("WebSocket 连接缺少认证令牌: ${request.uri}")
|
if (username != null) {
|
||||||
|
attributes["username"] = username
|
||||||
|
logger.debug("WebSocket 连接票据认证成功: username=$username")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
logger.warn("WebSocket 连接票据验证失败(可能已过期或已使用)")
|
||||||
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
|
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 兼容旧方式:使用 JWT token(不推荐,但保持向后兼容)
|
||||||
|
val token = getTokenFromRequest(request)
|
||||||
|
|
||||||
|
if (token == null) {
|
||||||
|
logger.warn("WebSocket 连接缺少认证令牌: ${request.uri.path}")
|
||||||
|
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 验证 token
|
// 验证 token
|
||||||
if (!jwtUtils.validateToken(token)) {
|
if (!jwtUtils.validateToken(token)) {
|
||||||
logger.warn("WebSocket 连接 token 验证失败: ${request.uri}")
|
logger.warn("WebSocket 连接 token 验证失败")
|
||||||
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
|
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证tokenVersion(检查token是否因密码修改而失效)
|
// 验证tokenVersion(检查token是否因密码修改而失效)
|
||||||
val username = jwtUtils.getUsernameFromToken(token)
|
val username = jwtUtils.getUsernameFromToken(token)
|
||||||
if (username != null) {
|
if (username != null) {
|
||||||
@@ -50,21 +66,21 @@ class WebSocketAuthInterceptor(
|
|||||||
if (user != null) {
|
if (user != null) {
|
||||||
val tokenVersion = jwtUtils.getTokenVersionFromToken(token)
|
val tokenVersion = jwtUtils.getTokenVersionFromToken(token)
|
||||||
if (tokenVersion == null || tokenVersion != user.tokenVersion) {
|
if (tokenVersion == null || tokenVersion != user.tokenVersion) {
|
||||||
logger.warn("WebSocket 连接 token 版本不匹配,token已失效: username=$username, tokenVersion=$tokenVersion, userTokenVersion=${user.tokenVersion}, uri=${request.uri}")
|
logger.warn("WebSocket 连接 token 版本不匹配,token已失效: username=$username")
|
||||||
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
|
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户名并存入 attributes,供后续使用
|
// 获取用户名并存入 attributes,供后续使用
|
||||||
attributes["username"] = username
|
attributes["username"] = username
|
||||||
logger.debug("WebSocket 连接认证成功: username=$username, uri=${request.uri}")
|
logger.debug("WebSocket 连接 JWT 认证成功: username=$username")
|
||||||
} else {
|
} else {
|
||||||
logger.warn("WebSocket 连接无法获取用户名: ${request.uri}")
|
logger.warn("WebSocket 连接无法获取用户名")
|
||||||
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
|
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +94,22 @@ class WebSocketAuthInterceptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从请求中获取 token
|
* 从请求中获取票据
|
||||||
|
*/
|
||||||
|
private fun getTicketFromRequest(request: ServerHttpRequest): String? {
|
||||||
|
val queryParams = request.uri.query ?: return null
|
||||||
|
val params = queryParams.split("&")
|
||||||
|
for (param in params) {
|
||||||
|
val parts = param.split("=", limit = 2)
|
||||||
|
if (parts.size == 2 && parts[0] == "ticket") {
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求中获取 token(兼容旧方式)
|
||||||
* 支持从查询参数 token 或请求头 Authorization 获取
|
* 支持从查询参数 token 或请求头 Authorization 获取
|
||||||
*/
|
*/
|
||||||
private fun getTokenFromRequest(request: ServerHttpRequest): String? {
|
private fun getTokenFromRequest(request: ServerHttpRequest): String? {
|
||||||
@@ -93,13 +124,13 @@ class WebSocketAuthInterceptor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从请求头获取
|
// 从请求头获取
|
||||||
val authHeader = request.headers.getFirst("Authorization")
|
val authHeader = request.headers.getFirst("Authorization")
|
||||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||||
return authHeader.substring(7)
|
return authHeader.substring(7)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.config
|
|||||||
|
|
||||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketHandler
|
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketHandler
|
||||||
import com.wrbug.polymarketbot.websocket.UnifiedWebSocketHandler
|
import com.wrbug.polymarketbot.websocket.UnifiedWebSocketHandler
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import org.springframework.web.socket.config.annotation.EnableWebSocket
|
import org.springframework.web.socket.config.annotation.EnableWebSocket
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer
|
import org.springframework.web.socket.config.annotation.WebSocketConfigurer
|
||||||
@@ -16,21 +17,46 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
|
|||||||
class WebSocketConfig(
|
class WebSocketConfig(
|
||||||
private val polymarketWebSocketHandler: PolymarketWebSocketHandler,
|
private val polymarketWebSocketHandler: PolymarketWebSocketHandler,
|
||||||
private val unifiedWebSocketHandler: UnifiedWebSocketHandler,
|
private val unifiedWebSocketHandler: UnifiedWebSocketHandler,
|
||||||
private val webSocketAuthInterceptor: WebSocketAuthInterceptor
|
private val webSocketAuthInterceptor: WebSocketAuthInterceptor,
|
||||||
|
@Value("\${websocket.allowed-origins:}") private val allowedOriginsConfig: String
|
||||||
) : WebSocketConfigurer {
|
) : WebSocketConfigurer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取允许的 WebSocket 来源
|
||||||
|
* 如果配置了 WEBSOCKET_ALLOWED_ORIGINS 环境变量,使用配置的域名
|
||||||
|
* 否则使用 setAllowedOriginPatterns 允许同源访问
|
||||||
|
*/
|
||||||
|
private fun getAllowedOrigins(): Array<String> {
|
||||||
|
return if (allowedOriginsConfig.isNotBlank()) {
|
||||||
|
allowedOriginsConfig.split(",").map { it.trim() }.toTypedArray()
|
||||||
|
} else {
|
||||||
|
emptyArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
|
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
|
||||||
|
val origins = getAllowedOrigins()
|
||||||
|
|
||||||
// Polymarket RTDS 转发端点(转发外部 Polymarket 实时数据流)
|
// Polymarket RTDS 转发端点(转发外部 Polymarket 实时数据流)
|
||||||
// 注意:此端点不需要鉴权,因为它只是转发外部数据
|
// 注意:此端点不需要鉴权,因为它只是转发外部数据
|
||||||
registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket")
|
val polymarketHandler = registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket")
|
||||||
.setAllowedOrigins("*") // 生产环境应该配置具体的域名
|
if (origins.isNotEmpty()) {
|
||||||
|
polymarketHandler.setAllowedOrigins(*origins)
|
||||||
|
} else {
|
||||||
|
// 使用 setAllowedOriginPatterns 替代 setAllowedOrigins("*"),更安全
|
||||||
|
polymarketHandler.setAllowedOriginPatterns("*")
|
||||||
|
}
|
||||||
|
|
||||||
// 统一 WebSocket 端点(所有推送服务统一使用此路径,通过 channel 区分)
|
// 统一 WebSocket 端点(所有推送服务统一使用此路径,通过 channel 区分)
|
||||||
// 支持的频道:position(仓位推送)、order(订单推送,待实现)等
|
// 支持的频道:position(仓位推送)、order(订单推送)等
|
||||||
// 需要 JWT 鉴权
|
// 需要 JWT 鉴权
|
||||||
registry.addHandler(unifiedWebSocketHandler, "/ws")
|
val unifiedHandler = registry.addHandler(unifiedWebSocketHandler, "/ws")
|
||||||
.addInterceptors(webSocketAuthInterceptor) // 添加鉴权拦截器
|
.addInterceptors(webSocketAuthInterceptor)
|
||||||
.setAllowedOrigins("*") // 生产环境应该配置具体的域名
|
if (origins.isNotEmpty()) {
|
||||||
|
unifiedHandler.setAllowedOrigins(*origins)
|
||||||
|
} else {
|
||||||
|
unifiedHandler.setAllowedOriginPatterns("*")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.wrbug.polymarketbot.constants
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket API 常量
|
||||||
|
* 集中管理所有 Polymarket API 的 URL 配置
|
||||||
|
*/
|
||||||
|
object PolymarketConstants {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket CLOB API 基础 URL
|
||||||
|
*/
|
||||||
|
const val CLOB_BASE_URL = "https://clob.polymarket.com"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket RTDS WebSocket URL
|
||||||
|
* 用于订单推送服务
|
||||||
|
*/
|
||||||
|
const val RTDS_WS_URL = "wss://ws-subscriptions-clob.polymarket.com"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket User Channel WebSocket URL
|
||||||
|
* 用于跟单服务(订阅 Leader 交易)
|
||||||
|
*/
|
||||||
|
const val USER_WS_URL = "wss://ws-live-data.polymarket.com"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket Activity WebSocket URL
|
||||||
|
* 用于 Activity 全局交易流监听
|
||||||
|
*/
|
||||||
|
const val ACTIVITY_WS_URL = "wss://ws-live-data.polymarket.com"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket Data API 基础 URL
|
||||||
|
*/
|
||||||
|
const val DATA_API_BASE_URL = "https://data-api.polymarket.com"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket Gamma API 基础 URL
|
||||||
|
*/
|
||||||
|
const val GAMMA_BASE_URL = "https://gamma-api.polymarket.com"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder Relayer API URL
|
||||||
|
* 用于 Gasless 交易
|
||||||
|
*/
|
||||||
|
const val BUILDER_RELAYER_URL = "https://relayer-v2.polymarket.com/"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket Safe 代理工厂合约地址(Polygon 主网)
|
||||||
|
* 用于 Safe 类型账户的代理部署(SAFE-CREATE)
|
||||||
|
*/
|
||||||
|
const val SAFE_PROXY_FACTORY_ADDRESS = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
|
||||||
|
|
||||||
|
/** SafeCreate 用 EIP-712 domain name,与 builder-relayer-client 一致 */
|
||||||
|
const val SAFE_FACTORY_EIP712_NAME = "Polymarket Contract Proxy Factory"
|
||||||
|
}
|
||||||
|
|
||||||
+178
-7
@@ -23,6 +23,50 @@ class AccountController(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(AccountController::class.java)
|
private val logger = LoggerFactory.getLogger(AccountController::class.java)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查代理地址选项(用于导入前选择代理类型)
|
||||||
|
*/
|
||||||
|
@PostMapping("/check-proxy-options")
|
||||||
|
fun checkProxyOptions(@RequestBody request: CheckProxyOptionsRequest): ResponseEntity<ApiResponse<CheckProxyOptionsResponse>> {
|
||||||
|
return try {
|
||||||
|
if (request.walletAddress.isBlank()) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_WALLET_ADDRESS_EMPTY, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "必须提供私钥或助记词", messageSource))
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = runBlocking { accountService.checkProxyOptions(request) }
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { response ->
|
||||||
|
ResponseEntity.ok(ApiResponse.success(response))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("检查代理地址选项失败: ${e.message}", e)
|
||||||
|
when (e) {
|
||||||
|
is IllegalArgumentException -> ResponseEntity.ok(
|
||||||
|
ApiResponse.error(
|
||||||
|
ErrorCode.PARAM_ERROR,
|
||||||
|
e.message,
|
||||||
|
messageSource
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else -> ResponseEntity.ok(
|
||||||
|
ApiResponse.error(
|
||||||
|
ErrorCode.SERVER_ERROR,
|
||||||
|
e.message,
|
||||||
|
messageSource
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("检查代理地址选项异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过私钥导入账户
|
* 通过私钥导入账户
|
||||||
*/
|
*/
|
||||||
@@ -45,14 +89,17 @@ class AccountController(
|
|||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
logger.error("导入账户失败: ${e.message}", e)
|
logger.error("导入账户失败: ${e.message}", e)
|
||||||
when (e) {
|
when (e) {
|
||||||
is IllegalArgumentException -> ResponseEntity.ok(
|
is IllegalArgumentException -> if (e.message == "ACCOUNT_ALREADY_EXISTS") {
|
||||||
ApiResponse.error(
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.ACCOUNT_ALREADY_EXISTS, messageSource = messageSource))
|
||||||
ErrorCode.PARAM_ERROR,
|
} else {
|
||||||
e.message,
|
ResponseEntity.ok(
|
||||||
messageSource
|
ApiResponse.error(
|
||||||
|
ErrorCode.PARAM_ERROR,
|
||||||
|
e.message,
|
||||||
|
messageSource
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message, messageSource))
|
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message, messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +204,82 @@ class AccountController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查账户设置状态(代理部署、交易启用、代币批准)
|
||||||
|
*/
|
||||||
|
@PostMapping("/check-setup-status")
|
||||||
|
fun checkSetupStatus(@RequestBody request: AccountDetailRequest): ResponseEntity<ApiResponse<AccountSetupStatusDto>> {
|
||||||
|
return try {
|
||||||
|
if (request.accountId == null || request.accountId <= 0) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
val result = runBlocking { accountService.checkAccountSetupStatus(request.accountId) }
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { status ->
|
||||||
|
ResponseEntity.ok(ApiResponse.success(status))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("检查账户设置状态失败: ${e.message}", e)
|
||||||
|
when (e) {
|
||||||
|
is IllegalArgumentException -> ResponseEntity.ok(
|
||||||
|
ApiResponse.error(
|
||||||
|
ErrorCode.PARAM_ERROR,
|
||||||
|
e.message,
|
||||||
|
messageSource
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else -> ResponseEntity.ok(
|
||||||
|
ApiResponse.error(
|
||||||
|
ErrorCode.SERVER_ERROR,
|
||||||
|
e.message,
|
||||||
|
messageSource
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("检查账户设置状态异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行设置步骤(步骤1 返回跳转 URL,步骤2/3 由后端执行)
|
||||||
|
*/
|
||||||
|
@PostMapping("/execute-setup-step")
|
||||||
|
fun executeSetupStep(@RequestBody request: ExecuteSetupStepRequest): ResponseEntity<ApiResponse<ExecuteSetupStepResponse>> {
|
||||||
|
return try {
|
||||||
|
if (request.accountId == null || request.accountId <= 0) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
val step = request.step ?: 0
|
||||||
|
if (step !in 1..3) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "步骤必须为 1、2 或 3", messageSource))
|
||||||
|
}
|
||||||
|
val result = runBlocking { accountService.executeSetupStep(request.accountId, step) }
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { response ->
|
||||||
|
ResponseEntity.ok(ApiResponse.success(response))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("执行设置步骤失败: ${e.message}", e)
|
||||||
|
when (e) {
|
||||||
|
is IllegalArgumentException -> ResponseEntity.ok(
|
||||||
|
ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource)
|
||||||
|
)
|
||||||
|
else -> ResponseEntity.ok(
|
||||||
|
ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("执行设置步骤异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询账户详情
|
* 查询账户详情
|
||||||
*/
|
*/
|
||||||
@@ -449,5 +572,53 @@ class AccountController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 USDC.e wrap 为 pUSD(V2 迁移)
|
||||||
|
*/
|
||||||
|
@PostMapping("/wrap-to-pusd")
|
||||||
|
fun wrapToPusd(@RequestBody request: Map<String, Any>): ResponseEntity<ApiResponse<Map<String, String?>>> {
|
||||||
|
return try {
|
||||||
|
val accountId = (request["accountId"] as? Number)?.toLong()
|
||||||
|
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||||
|
val result = runBlocking { accountService.wrapUsdcToPusd(accountId) }
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { txHash ->
|
||||||
|
ResponseEntity.ok(ApiResponse.success(mapOf("transactionHash" to txHash)))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("USDC.e → pUSD wrap 失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("USDC.e → pUSD wrap 异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询 USDC.e 余额(V2 迁移用)
|
||||||
|
*/
|
||||||
|
@PostMapping("/usdce-balance")
|
||||||
|
fun getUsdceBalance(@RequestBody request: Map<String, Any>): ResponseEntity<ApiResponse<Map<String, String>>> {
|
||||||
|
return try {
|
||||||
|
val accountId = (request["accountId"] as? Number)?.toLong()
|
||||||
|
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||||
|
val result = runBlocking { accountService.getUsdceBalance(accountId) }
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { balance ->
|
||||||
|
ResponseEntity.ok(ApiResponse.success(mapOf("balance" to balance.toPlainString())))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("查询 USDC.e 余额失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("查询 USDC.e 余额异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.wrbug.polymarketbot.controller.auth
|
|||||||
|
|
||||||
import com.wrbug.polymarketbot.dto.*
|
import com.wrbug.polymarketbot.dto.*
|
||||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||||
|
import com.wrbug.polymarketbot.repository.UserRepository
|
||||||
import com.wrbug.polymarketbot.service.auth.AuthService
|
import com.wrbug.polymarketbot.service.auth.AuthService
|
||||||
|
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.context.MessageSource
|
import org.springframework.context.MessageSource
|
||||||
@@ -16,7 +18,9 @@ import org.springframework.web.bind.annotation.*
|
|||||||
@RequestMapping("/api/auth")
|
@RequestMapping("/api/auth")
|
||||||
class AuthController(
|
class AuthController(
|
||||||
private val authService: AuthService,
|
private val authService: AuthService,
|
||||||
private val messageSource: MessageSource
|
private val messageSource: MessageSource,
|
||||||
|
private val webSocketTicketService: WebSocketTicketService,
|
||||||
|
private val userRepository: UserRepository
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(AuthController::class.java)
|
private val logger = LoggerFactory.getLogger(AuthController::class.java)
|
||||||
@@ -25,7 +29,10 @@ class AuthController(
|
|||||||
* 登录接口
|
* 登录接口
|
||||||
*/
|
*/
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
fun login(@RequestBody request: LoginRequest): ResponseEntity<ApiResponse<LoginResponse>> {
|
fun login(
|
||||||
|
@RequestBody request: LoginRequest,
|
||||||
|
httpRequest: HttpServletRequest
|
||||||
|
): ResponseEntity<ApiResponse<LoginResponse>> {
|
||||||
return try {
|
return try {
|
||||||
if (request.username.isBlank()) {
|
if (request.username.isBlank()) {
|
||||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "用户名不能为空", messageSource))
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "用户名不能为空", messageSource))
|
||||||
@@ -33,15 +40,19 @@ class AuthController(
|
|||||||
if (request.password.isBlank()) {
|
if (request.password.isBlank()) {
|
||||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "密码不能为空", messageSource))
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "密码不能为空", messageSource))
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = authService.login(request.username, request.password)
|
val ipAddress = getClientIpAddress(httpRequest)
|
||||||
|
val result = authService.login(request.username, request.password, ipAddress)
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { loginResponse ->
|
onSuccess = { loginResponse ->
|
||||||
ResponseEntity.ok(ApiResponse.success(loginResponse))
|
ResponseEntity.ok(ApiResponse.success(loginResponse))
|
||||||
},
|
},
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
logger.error("登录失败: ${e.message}", e)
|
|
||||||
when (e) {
|
when (e) {
|
||||||
|
is IllegalStateException -> {
|
||||||
|
// 限速或锁定错误
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, e.message ?: "登录失败", messageSource))
|
||||||
|
}
|
||||||
is IllegalArgumentException -> {
|
is IllegalArgumentException -> {
|
||||||
if (e.message == ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message) {
|
if (e.message == ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message) {
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR, messageSource = messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR, messageSource = messageSource))
|
||||||
@@ -49,15 +60,36 @@ class AuthController(
|
|||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败: ${e.message}", messageSource))
|
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败", messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("登录异常: ${e.message}", e)
|
logger.error("登录异常: ${e.message}", e)
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败: ${e.message}", messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败", messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取客户端IP地址
|
||||||
|
*/
|
||||||
|
private fun getClientIpAddress(request: HttpServletRequest): String {
|
||||||
|
var ip = request.getHeader("X-Forwarded-For")
|
||||||
|
if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) {
|
||||||
|
ip = request.getHeader("X-Real-IP")
|
||||||
|
}
|
||||||
|
if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) {
|
||||||
|
ip = request.getHeader("Proxy-Client-IP")
|
||||||
|
}
|
||||||
|
if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) {
|
||||||
|
ip = request.remoteAddr
|
||||||
|
}
|
||||||
|
// 处理多个IP的情况
|
||||||
|
if (ip.contains(",")) {
|
||||||
|
ip = ip.split(",")[0].trim()
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重置密码接口
|
* 重置密码接口
|
||||||
@@ -132,5 +164,54 @@ class AuthController(
|
|||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "检查首次使用失败: ${e.message}", messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "检查首次使用失败: ${e.message}", messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 WebSocket 连接票据
|
||||||
|
* 返回一个短期有效(30秒)的一次性票据,用于 WebSocket 连接认证
|
||||||
|
* 避免在 WebSocket URL 中暴露 JWT
|
||||||
|
*/
|
||||||
|
@PostMapping("/ws-ticket")
|
||||||
|
fun getWebSocketTicket(httpRequest: HttpServletRequest): ResponseEntity<ApiResponse<WebSocketTicketResponse>> {
|
||||||
|
return try {
|
||||||
|
// 从请求属性中获取用户名(由 JWT 拦截器设置)
|
||||||
|
val username = httpRequest.getAttribute("username") as? String
|
||||||
|
if (username == null) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "未认证", messageSource))
|
||||||
|
}
|
||||||
|
|
||||||
|
val ticket = webSocketTicketService.generateTicket(username)
|
||||||
|
ResponseEntity.ok(ApiResponse.success(WebSocketTicketResponse(ticket = ticket)))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("获取 WebSocket 票据异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取票据失败", messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证当前用户权限
|
||||||
|
* 用于动态更新服务验证管理员权限
|
||||||
|
* 管理员权限判断:是否为默认账户(isDefault == true)
|
||||||
|
*/
|
||||||
|
@GetMapping("/verify")
|
||||||
|
fun verify(httpRequest: HttpServletRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||||
|
return try {
|
||||||
|
// 从请求属性中获取用户名(由 JWT 拦截器设置)
|
||||||
|
val username = httpRequest.getAttribute("username") as? String
|
||||||
|
if (username == null) {
|
||||||
|
return ResponseEntity.status(401).body(ApiResponse.error(ErrorCode.AUTH_ERROR, "未认证", messageSource))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为默认账户(管理员)
|
||||||
|
val user = userRepository.findByUsername(username)
|
||||||
|
if (user == null || !user.isDefault) {
|
||||||
|
return ResponseEntity.status(403).body(ApiResponse.error(ErrorCode.AUTH_ERROR, "需要管理员权限", messageSource))
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("验证权限异常: ${e.message}", e)
|
||||||
|
ResponseEntity.status(500).body(ApiResponse.error(ErrorCode.SERVER_ERROR, "验证失败", messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+256
@@ -0,0 +1,256 @@
|
|||||||
|
package com.wrbug.polymarketbot.controller.backtest
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.dto.*
|
||||||
|
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||||
|
import com.wrbug.polymarketbot.service.backtest.BacktestService
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.context.MessageSource
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测管理控制器
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/backtest")
|
||||||
|
class BacktestController(
|
||||||
|
private val backtestService: BacktestService,
|
||||||
|
private val messageSource: MessageSource
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(BacktestController::class.java)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建回测任务
|
||||||
|
*/
|
||||||
|
@PostMapping("/tasks")
|
||||||
|
fun createBacktestTask(@RequestBody request: BacktestCreateRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
|
||||||
|
return try {
|
||||||
|
logger.info("创建回测任务: taskName=${request.taskName}, leaderId=${request.leaderId}")
|
||||||
|
|
||||||
|
val result = runBlocking {
|
||||||
|
backtestService.createBacktestTask(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { dto ->
|
||||||
|
logger.info("回测任务创建成功: taskId=${dto.id}")
|
||||||
|
ResponseEntity.ok(ApiResponse.success(dto))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("创建回测任务失败", e)
|
||||||
|
val errorCode = when (e) {
|
||||||
|
is IllegalArgumentException -> ErrorCode.PARAM_ERROR
|
||||||
|
else -> ErrorCode.SERVER_BACKTEST_CREATE_FAILED
|
||||||
|
}
|
||||||
|
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("创建回测任务异常", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_CREATE_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询回测任务列表
|
||||||
|
*/
|
||||||
|
@PostMapping("/tasks/list")
|
||||||
|
fun getBacktestTaskList(@RequestBody request: BacktestListRequest): ResponseEntity<ApiResponse<BacktestListResponse>> {
|
||||||
|
return try {
|
||||||
|
val result = backtestService.getBacktestTaskList(request)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { response ->
|
||||||
|
logger.info("查询回测任务列表成功: total=${response.total}")
|
||||||
|
ResponseEntity.ok(ApiResponse.success(response))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("查询回测任务列表失败", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("查询回测任务列表异常", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询回测任务详情
|
||||||
|
*/
|
||||||
|
@PostMapping("/tasks/detail")
|
||||||
|
fun getBacktestTaskDetail(@RequestBody request: BacktestDetailRequest): ResponseEntity<ApiResponse<BacktestDetailResponse>> {
|
||||||
|
return try {
|
||||||
|
val result = backtestService.getBacktestTaskDetail(request)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { response ->
|
||||||
|
logger.info("查询回测任务详情成功: taskId=${request.id}")
|
||||||
|
ResponseEntity.ok(ApiResponse.success(response))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("查询回测任务详情失败", e)
|
||||||
|
val errorCode = when (e) {
|
||||||
|
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||||
|
else -> ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED
|
||||||
|
}
|
||||||
|
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("查询回测任务详情异常", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询回测交易记录
|
||||||
|
*/
|
||||||
|
@PostMapping("/tasks/trades")
|
||||||
|
fun getBacktestTrades(@RequestBody request: BacktestTradeListRequest): ResponseEntity<ApiResponse<BacktestTradeListResponse>> {
|
||||||
|
return try {
|
||||||
|
val result = backtestService.getBacktestTrades(request)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { response ->
|
||||||
|
logger.info("查询回测交易记录成功: taskId=${request.taskId}")
|
||||||
|
ResponseEntity.ok(ApiResponse.success(response))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("查询回测交易记录失败", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("查询回测交易记录异常", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除回测任务
|
||||||
|
*/
|
||||||
|
@PostMapping("/tasks/delete")
|
||||||
|
fun deleteBacktestTask(@RequestBody request: BacktestDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||||
|
return try {
|
||||||
|
logger.info("删除回测任务: taskId=${request.id}")
|
||||||
|
|
||||||
|
val result = backtestService.deleteBacktestTask(request)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = {
|
||||||
|
logger.info("回测任务删除成功: taskId=${request.id}")
|
||||||
|
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("删除回测任务失败", e)
|
||||||
|
val errorCode = when (e) {
|
||||||
|
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||||
|
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
|
||||||
|
else -> ErrorCode.SERVER_BACKTEST_DELETE_FAILED
|
||||||
|
}
|
||||||
|
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("删除回测任务异常", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DELETE_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止回测任务
|
||||||
|
*/
|
||||||
|
@PostMapping("/tasks/stop")
|
||||||
|
fun stopBacktestTask(@RequestBody request: BacktestStopRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||||
|
return try {
|
||||||
|
logger.info("停止回测任务: taskId=${request.id}")
|
||||||
|
|
||||||
|
val result = backtestService.stopBacktestTask(request)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = {
|
||||||
|
logger.info("回测任务停止成功: taskId=${request.id}")
|
||||||
|
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("停止回测任务失败", e)
|
||||||
|
val errorCode = when (e) {
|
||||||
|
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||||
|
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
|
||||||
|
else -> ErrorCode.SERVER_BACKTEST_STOP_FAILED
|
||||||
|
}
|
||||||
|
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("停止回测任务异常", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_STOP_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试回测任务
|
||||||
|
*/
|
||||||
|
@PostMapping("/tasks/retry")
|
||||||
|
fun retryBacktestTask(@RequestBody request: BacktestRetryRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||||
|
return try {
|
||||||
|
logger.info("重试回测任务: taskId=${request.id}")
|
||||||
|
|
||||||
|
val result = backtestService.retryBacktestTask(request)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = {
|
||||||
|
logger.info("回测任务重试成功: taskId=${request.id}")
|
||||||
|
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("重试回测任务失败", e)
|
||||||
|
val errorCode = when (e) {
|
||||||
|
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||||
|
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
|
||||||
|
else -> ErrorCode.SERVER_BACKTEST_RETRY_FAILED
|
||||||
|
}
|
||||||
|
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("重试回测任务异常", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RETRY_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按当前配置重新测试:基于已完成的回测任务创建相同配置的新任务(仅支持已完成任务)
|
||||||
|
*/
|
||||||
|
@PostMapping("/tasks/rerun")
|
||||||
|
fun rerunBacktestTask(@RequestBody request: BacktestRerunRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
|
||||||
|
return try {
|
||||||
|
logger.info("按配置重新测试: sourceTaskId=${request.id}, newTaskName=${request.taskName}")
|
||||||
|
|
||||||
|
val result = backtestService.rerunBacktestTask(request)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { dto ->
|
||||||
|
logger.info("重新测试任务创建成功: newTaskId=${dto.id}")
|
||||||
|
ResponseEntity.ok(ApiResponse.success(dto))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("按配置重新测试失败", e)
|
||||||
|
val errorCode = when (e) {
|
||||||
|
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||||
|
is IllegalStateException -> ErrorCode.BACKTEST_TASK_NOT_COMPLETED
|
||||||
|
else -> ErrorCode.SERVER_BACKTEST_RERUN_FAILED
|
||||||
|
}
|
||||||
|
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("按配置重新测试异常", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RERUN_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+31
-1
@@ -140,7 +140,7 @@ class LeaderController(
|
|||||||
if (request.leaderId <= 0) {
|
if (request.leaderId <= 0) {
|
||||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = leaderService.getLeaderDetail(request.leaderId)
|
val result = leaderService.getLeaderDetail(request.leaderId)
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { leader ->
|
onSuccess = { leader ->
|
||||||
@@ -159,6 +159,36 @@ class LeaderController(
|
|||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_DETAIL_FETCH_FAILED, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_DETAIL_FETCH_FAILED, e.message, messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询被跟单者余额
|
||||||
|
*/
|
||||||
|
@PostMapping("/balance")
|
||||||
|
fun getLeaderBalance(@RequestBody request: LeaderBalanceRequest): ResponseEntity<ApiResponse<LeaderBalanceResponse>> {
|
||||||
|
return try {
|
||||||
|
if (request.leaderId <= 0) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = leaderService.getLeaderBalance(request.leaderId)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { balance ->
|
||||||
|
ResponseEntity.ok(ApiResponse.success(balance))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("查询 Leader 余额失败: ${e.message}", e)
|
||||||
|
when (e) {
|
||||||
|
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
|
||||||
|
is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.BUSINESS_ERROR, e.message, messageSource))
|
||||||
|
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("查询 Leader 余额异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+46
@@ -200,5 +200,51 @@ class CopyOrderTrackingController(
|
|||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ORDER_TRACKING_LIST_FETCH_FAILED, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ORDER_TRACKING_LIST_FETCH_FAILED, e.message, messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询按市场分组的订单列表(买入/卖出)
|
||||||
|
* POST /api/copy-trading/orders/grouped-by-market
|
||||||
|
*/
|
||||||
|
@PostMapping("/grouped-by-market")
|
||||||
|
fun getOrderListGroupedByMarket(@RequestBody request: MarketGroupedOrdersRequest): ResponseEntity<ApiResponse<MarketGroupedOrdersResponse>> {
|
||||||
|
return try {
|
||||||
|
if (request.copyTradingId <= 0) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_COPY_TRADING_ID_INVALID, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.type.isBlank()) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "订单类型不能为空", messageSource))
|
||||||
|
}
|
||||||
|
|
||||||
|
val validTypes = listOf("buy", "sell")
|
||||||
|
if (!validTypes.contains(request.type.lowercase())) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "订单类型必须是 buy 或 sell", messageSource))
|
||||||
|
}
|
||||||
|
|
||||||
|
val result: Result<MarketGroupedOrdersResponse> = runBlocking {
|
||||||
|
when (request.type.lowercase()) {
|
||||||
|
"buy" -> statisticsService.getBuyOrderListGroupedByMarket(request)
|
||||||
|
"sell" -> statisticsService.getSellOrderListGroupedByMarket(request)
|
||||||
|
else -> Result.failure(IllegalArgumentException("不支持的订单类型: ${request.type}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { response: MarketGroupedOrdersResponse ->
|
||||||
|
ResponseEntity.ok(ApiResponse.success(response))
|
||||||
|
},
|
||||||
|
onFailure = { e: Throwable ->
|
||||||
|
logger.error("查询按市场分组的订单列表失败: copyTradingId=${request.copyTradingId}, type=${request.type}", e)
|
||||||
|
when (e) {
|
||||||
|
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
|
||||||
|
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ORDER_TRACKING_LIST_FETCH_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("查询按市场分组的订单列表异常: copyTradingId=${request.copyTradingId}, type=${request.type}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ORDER_TRACKING_LIST_FETCH_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+283
@@ -0,0 +1,283 @@
|
|||||||
|
package com.wrbug.polymarketbot.controller.cryptotail
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.dto.ApiResponse
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailStrategyCreateRequest
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailStrategyDeleteRequest
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailStrategyDto
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailStrategyListRequest
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailStrategyListResponse
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListRequest
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListResponse
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailStrategyUpdateRequest
|
||||||
|
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.dto.CryptoTailPnlCurveRequest
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailPnlCurveResponse
|
||||||
|
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
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(CryptoTailStrategyController::class.java)
|
||||||
|
|
||||||
|
@PostMapping("/list")
|
||||||
|
fun list(@RequestBody request: CryptoTailStrategyListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyListResponse>> {
|
||||||
|
return try {
|
||||||
|
val result = cryptoTailStrategyService.list(request)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("查询加密价差策略列表失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("查询加密价差策略列表异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/create")
|
||||||
|
fun create(@RequestBody request: CryptoTailStrategyCreateRequest): ResponseEntity<ApiResponse<CryptoTailStrategyDto>> {
|
||||||
|
return try {
|
||||||
|
val result = cryptoTailStrategyService.create(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_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
|
||||||
|
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED
|
||||||
|
ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID
|
||||||
|
ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID
|
||||||
|
else -> ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED
|
||||||
|
}
|
||||||
|
ResponseEntity.ok(ApiResponse.error(code, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("创建加密价差策略异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/update")
|
||||||
|
fun update(@RequestBody request: CryptoTailStrategyUpdateRequest): ResponseEntity<ApiResponse<CryptoTailStrategyDto>> {
|
||||||
|
return try {
|
||||||
|
if (request.strategyId <= 0) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
val result = cryptoTailStrategyService.update(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.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND
|
||||||
|
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
|
||||||
|
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED
|
||||||
|
ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID
|
||||||
|
else -> ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED
|
||||||
|
}
|
||||||
|
ResponseEntity.ok(ApiResponse.error(code, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("更新加密价差策略异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/delete")
|
||||||
|
fun delete(@RequestBody request: CryptoTailStrategyDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||||
|
return try {
|
||||||
|
val strategyId = request.strategyId
|
||||||
|
if (strategyId <= 0) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
val result = cryptoTailStrategyService.delete(strategyId)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("删除加密价差策略失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("删除加密价差策略异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/pnl-curve")
|
||||||
|
fun getPnlCurve(@RequestBody request: CryptoTailPnlCurveRequest): ResponseEntity<ApiResponse<CryptoTailPnlCurveResponse>> {
|
||||||
|
return try {
|
||||||
|
if (request.strategyId <= 0) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
val result = cryptoTailStrategyService.getPnlCurve(request)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("查询收益曲线失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("查询收益曲线异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/triggers")
|
||||||
|
fun getTriggerRecords(@RequestBody request: CryptoTailStrategyTriggerListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyTriggerListResponse>> {
|
||||||
|
return try {
|
||||||
|
if (request.strategyId <= 0) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
val result = cryptoTailStrategyService.getTriggerRecords(request)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("查询触发记录失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("查询触发记录异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/market-options")
|
||||||
|
fun getMarketOptions(): ResponseEntity<ApiResponse<List<CryptoTailMarketOptionDto>>> {
|
||||||
|
return try {
|
||||||
|
val options = listOf(
|
||||||
|
CryptoTailMarketOptionDto(slug = "btc-updown-5m", title = "Bitcoin Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "btc-updown-15m", title = "Bitcoin Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "eth-updown-5m", title = "Ethereum Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "eth-updown-15m", title = "Ethereum Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "sol-updown-5m", title = "Solana Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "sol-updown-15m", title = "Solana Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "xrp-updown-5m", title = "XRP Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "xrp-updown-15m", title = "XRP Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null)
|
||||||
|
)
|
||||||
|
ResponseEntity.ok(ApiResponse.success(options))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("获取市场选项异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动最小价差预览:按「当前周期」计算一次并返回,仅用于前端展示参考。
|
||||||
|
* 实际触发时按每个周期在需要时计算,不依赖此接口。
|
||||||
|
*/
|
||||||
|
@PostMapping("/auto-min-spread")
|
||||||
|
fun getAutoMinSpread(@RequestBody request: java.util.Map<String, Any>): ResponseEntity<ApiResponse<CryptoTailAutoMinSpreadResponse>> {
|
||||||
|
return try {
|
||||||
|
val intervalSeconds = (request["intervalSeconds"] as? Number)?.toInt() ?: 300
|
||||||
|
if (intervalSeconds != 300 && intervalSeconds != 900) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
val periodStartUnix = (request["periodStartUnix"] as? Number)?.toLong()
|
||||||
|
?: ((System.currentTimeMillis() / 1000 / intervalSeconds) * intervalSeconds)
|
||||||
|
// 默认使用 BTC 市场(向后兼容)
|
||||||
|
val marketSlugPrefix = (request["marketSlugPrefix"] as? String) ?: "btc-updown"
|
||||||
|
val pair = binanceKlineAutoSpreadService.computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix)
|
||||||
|
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "fetch_failed", messageSource))
|
||||||
|
val body = CryptoTailAutoMinSpreadResponse(
|
||||||
|
minSpreadUp = pair.first.toPlainString(),
|
||||||
|
minSpreadDown = pair.second.toPlainString()
|
||||||
|
)
|
||||||
|
ResponseEntity.ok(ApiResponse.success(body))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("计算自动最小价差异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化加密价差策略监控
|
||||||
|
* 返回策略信息、开盘价、tokenIds等初始化数据
|
||||||
|
*/
|
||||||
|
@PostMapping("/monitor/init")
|
||||||
|
fun initMonitor(@RequestBody request: CryptoTailMonitorInitRequest): ResponseEntity<ApiResponse<CryptoTailMonitorInitResponse>> {
|
||||||
|
return try {
|
||||||
|
if (request.strategyId <= 0) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
val result = cryptoTailMonitorService.initMonitor(request)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("初始化加密价差策略监控失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("初始化加密价差策略监控异常: ${e.message}", e)
|
||||||
|
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" -> 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-11
@@ -4,8 +4,10 @@ import com.wrbug.polymarketbot.api.LatestPriceResponse
|
|||||||
import com.wrbug.polymarketbot.dto.*
|
import com.wrbug.polymarketbot.dto.*
|
||||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||||
|
import com.wrbug.polymarketbot.service.common.MarketPriceService
|
||||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.math.BigDecimal
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.context.MessageSource
|
import org.springframework.context.MessageSource
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
@@ -20,14 +22,16 @@ import org.springframework.web.bind.annotation.*
|
|||||||
class MarketController(
|
class MarketController(
|
||||||
private val accountService: AccountService,
|
private val accountService: AccountService,
|
||||||
private val clobService: PolymarketClobService,
|
private val clobService: PolymarketClobService,
|
||||||
|
private val marketPriceService: MarketPriceService,
|
||||||
private val messageSource: MessageSource
|
private val messageSource: MessageSource
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(MarketController::class.java)
|
private val logger = LoggerFactory.getLogger(MarketController::class.java)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取市场价格(通过 Gamma API)
|
* 获取市场价格
|
||||||
* 使用 Gamma API 获取价格信息,因为 Gamma API 支持 condition_ids 参数
|
* 使用 MarketPriceService 获取当前市场价格(支持多数据源降级)
|
||||||
|
* 返回当前价格,前端接收后自行填充到 bestBid 字段
|
||||||
*/
|
*/
|
||||||
@PostMapping("/price")
|
@PostMapping("/price")
|
||||||
fun getMarketPrice(@RequestBody request: MarketPriceRequest): ResponseEntity<ApiResponse<MarketPriceResponse>> {
|
fun getMarketPrice(@RequestBody request: MarketPriceRequest): ResponseEntity<ApiResponse<MarketPriceResponse>> {
|
||||||
@@ -36,16 +40,16 @@ class MarketController(
|
|||||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_MARKET_ID_EMPTY, messageSource = messageSource))
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_MARKET_ID_EMPTY, messageSource = messageSource))
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = runBlocking { accountService.getMarketPrice(request.marketId, request.outcomeIndex) }
|
val outcomeIndex = request.outcomeIndex ?: 0
|
||||||
result.fold(
|
val price = runBlocking {
|
||||||
onSuccess = { response ->
|
marketPriceService.getCurrentMarketPrice(request.marketId, outcomeIndex)
|
||||||
ResponseEntity.ok(ApiResponse.success(response))
|
}
|
||||||
},
|
|
||||||
onFailure = { e ->
|
val response = MarketPriceResponse(
|
||||||
logger.error("获取市场价格失败: ${e.message}", e)
|
marketId = request.marketId,
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_PRICE_FETCH_FAILED, e.message, messageSource))
|
currentPrice = price.toString()
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
ResponseEntity.ok(ApiResponse.success(response))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("获取市场价格异常: ${e.message}", e)
|
logger.error("获取市场价格异常: ${e.message}", e)
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_PRICE_FETCH_FAILED, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_PRICE_FETCH_FAILED, e.message, messageSource))
|
||||||
|
|||||||
+166
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.controller.system
|
|||||||
import com.wrbug.polymarketbot.dto.*
|
import com.wrbug.polymarketbot.dto.*
|
||||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||||
import com.wrbug.polymarketbot.service.system.NotificationConfigService
|
import com.wrbug.polymarketbot.service.system.NotificationConfigService
|
||||||
|
import com.wrbug.polymarketbot.service.system.NotificationTemplateService
|
||||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
@@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.*
|
|||||||
class NotificationController(
|
class NotificationController(
|
||||||
private val notificationConfigService: NotificationConfigService,
|
private val notificationConfigService: NotificationConfigService,
|
||||||
private val telegramNotificationService: TelegramNotificationService,
|
private val telegramNotificationService: TelegramNotificationService,
|
||||||
|
private val notificationTemplateService: NotificationTemplateService,
|
||||||
private val messageSource: MessageSource
|
private val messageSource: MessageSource
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -335,6 +337,155 @@ class NotificationController(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 模板相关 API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有模板类型
|
||||||
|
*/
|
||||||
|
@PostMapping("/templates/types")
|
||||||
|
fun getTemplateTypes(): ResponseEntity<ApiResponse<List<TemplateTypeInfoDto>>> {
|
||||||
|
return try {
|
||||||
|
val types = notificationTemplateService.getTemplateTypes()
|
||||||
|
ResponseEntity.ok(ApiResponse.success(types))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("获取模板类型失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有模板
|
||||||
|
*/
|
||||||
|
@PostMapping("/templates/list")
|
||||||
|
fun getTemplates(): ResponseEntity<ApiResponse<List<NotificationTemplateDto>>> {
|
||||||
|
return try {
|
||||||
|
val templates = notificationTemplateService.getAllTemplates()
|
||||||
|
ResponseEntity.ok(ApiResponse.success(templates))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("获取模板列表失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个模板
|
||||||
|
*/
|
||||||
|
@PostMapping("/templates/detail")
|
||||||
|
fun getTemplateDetail(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<NotificationTemplateDto>> {
|
||||||
|
return try {
|
||||||
|
if (request.templateType.isBlank()) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val template = notificationTemplateService.getTemplate(request.templateType)
|
||||||
|
if (template == null) {
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource))
|
||||||
|
} else {
|
||||||
|
ResponseEntity.ok(ApiResponse.success(template))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("获取模板详情失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模板可用变量
|
||||||
|
*/
|
||||||
|
@PostMapping("/templates/variables")
|
||||||
|
fun getTemplateVariables(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<TemplateVariablesResponse>> {
|
||||||
|
return try {
|
||||||
|
if (request.templateType.isBlank()) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val variables = notificationTemplateService.getTemplateVariables(request.templateType)
|
||||||
|
if (variables == null) {
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource))
|
||||||
|
} else {
|
||||||
|
ResponseEntity.ok(ApiResponse.success(variables))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("获取模板变量失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新模板
|
||||||
|
*/
|
||||||
|
@PostMapping("/templates/update")
|
||||||
|
fun updateTemplate(@RequestBody request: UpdateTemplateRequestWithId): ResponseEntity<ApiResponse<NotificationTemplateDto>> {
|
||||||
|
return try {
|
||||||
|
if (request.templateType.isBlank()) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
|
||||||
|
}
|
||||||
|
if (request.templateContent.isBlank()) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.paramError("模板内容不能为空"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val template = notificationTemplateService.updateTemplate(request.templateType, request.templateContent)
|
||||||
|
ResponseEntity.ok(ApiResponse.success(template))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("更新模板失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置模板为默认
|
||||||
|
*/
|
||||||
|
@PostMapping("/templates/reset")
|
||||||
|
fun resetTemplate(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<NotificationTemplateDto>> {
|
||||||
|
return try {
|
||||||
|
if (request.templateType.isBlank()) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val template = notificationTemplateService.resetTemplate(request.templateType)
|
||||||
|
if (template == null) {
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource))
|
||||||
|
} else {
|
||||||
|
ResponseEntity.ok(ApiResponse.success(template))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("重置模板失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送模板测试消息
|
||||||
|
*/
|
||||||
|
@PostMapping("/templates/test")
|
||||||
|
fun testTemplate(@RequestBody request: TestTemplateRequest): ResponseEntity<ApiResponse<Boolean>> {
|
||||||
|
return try {
|
||||||
|
if (request.templateType.isBlank()) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val success = runBlocking {
|
||||||
|
notificationTemplateService.sendTestMessage(request.templateType, request.templateContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
ResponseEntity.ok(ApiResponse.success(true))
|
||||||
|
} else {
|
||||||
|
ResponseEntity.ok(ApiResponse.error(
|
||||||
|
ErrorCode.NOTIFICATION_TEST_FAILED,
|
||||||
|
messageSource = messageSource
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("发送模板测试消息失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(
|
||||||
|
ErrorCode.NOTIFICATION_TEST_FAILED,
|
||||||
|
customMsg = "发送测试消息失败:${e.message}",
|
||||||
|
messageSource = messageSource
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -384,3 +535,18 @@ data class NotificationConfigDeleteRequest(
|
|||||||
val id: Long
|
val id: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板详情请求
|
||||||
|
*/
|
||||||
|
data class TemplateDetailRequest(
|
||||||
|
val templateType: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新模板请求(带类型)
|
||||||
|
*/
|
||||||
|
data class UpdateTemplateRequestWithId(
|
||||||
|
val templateType: String,
|
||||||
|
val templateContent: String
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
+28
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.controller.system
|
|||||||
import com.wrbug.polymarketbot.dto.*
|
import com.wrbug.polymarketbot.dto.*
|
||||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||||
import com.wrbug.polymarketbot.service.system.ApiHealthCheckService
|
import com.wrbug.polymarketbot.service.system.ApiHealthCheckService
|
||||||
|
import com.wrbug.polymarketbot.service.system.GeoblockService
|
||||||
import com.wrbug.polymarketbot.service.system.ProxyConfigService
|
import com.wrbug.polymarketbot.service.system.ProxyConfigService
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -19,6 +20,7 @@ import org.springframework.web.bind.annotation.*
|
|||||||
class ProxyConfigController(
|
class ProxyConfigController(
|
||||||
private val proxyConfigService: ProxyConfigService,
|
private val proxyConfigService: ProxyConfigService,
|
||||||
private val apiHealthCheckService: ApiHealthCheckService,
|
private val apiHealthCheckService: ApiHealthCheckService,
|
||||||
|
private val geoblockService: GeoblockService,
|
||||||
private val messageSource: MessageSource
|
private val messageSource: MessageSource
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -123,6 +125,32 @@ class ProxyConfigController(
|
|||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "API 健康检查失败:${e.message}", messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "API 健康检查失败:${e.message}", messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查交易服务器出口 IP 的地域限制(Polymarket Geoblock)
|
||||||
|
*/
|
||||||
|
@PostMapping("/geoblock-check")
|
||||||
|
fun checkGeoblock(): ResponseEntity<ApiResponse<GeoblockCheckDto>> {
|
||||||
|
return try {
|
||||||
|
val result = runBlocking { geoblockService.checkGeoblock() }
|
||||||
|
if (result.isSuccess) {
|
||||||
|
ResponseEntity.ok(ApiResponse.success(result.getOrNull()))
|
||||||
|
} else {
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
logger.error("Geoblock 检查失败", error)
|
||||||
|
ResponseEntity.ok(
|
||||||
|
ApiResponse.error(
|
||||||
|
ErrorCode.SERVER_ERROR,
|
||||||
|
error?.message ?: "Geoblock 检查失败",
|
||||||
|
messageSource
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("Geoblock 检查异常", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "Geoblock 检查失败:${e.message}", messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+366
@@ -0,0 +1,366 @@
|
|||||||
|
package com.wrbug.polymarketbot.controller.system
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.dto.ApiResponse
|
||||||
|
import com.wrbug.polymarketbot.entity.RpcNodeConfig
|
||||||
|
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||||
|
import com.wrbug.polymarketbot.service.system.AddRpcNodeRequest
|
||||||
|
import com.wrbug.polymarketbot.service.system.NodeCheckResult
|
||||||
|
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||||
|
import com.wrbug.polymarketbot.service.system.UpdateRpcNodeRequest
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.context.MessageSource
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 节点管理控制器
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/system/rpc-nodes")
|
||||||
|
class RpcNodeController(
|
||||||
|
private val rpcNodeService: RpcNodeService,
|
||||||
|
private val messageSource: MessageSource
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(RpcNodeController::class.java)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有节点列表
|
||||||
|
*/
|
||||||
|
@PostMapping("/list")
|
||||||
|
fun getAllNodes(@RequestBody request: Map<String, Any>?): ResponseEntity<ApiResponse<List<RpcNodeConfigDto>>> {
|
||||||
|
return try {
|
||||||
|
val nodes = rpcNodeService.getAllNodes()
|
||||||
|
val dtos = nodes.map { it.toDto() }
|
||||||
|
ResponseEntity.ok(ApiResponse.success(dtos))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("获取 RPC 节点列表失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(
|
||||||
|
ErrorCode.SERVER_ERROR,
|
||||||
|
customMsg = "获取节点列表失败:${e.message}",
|
||||||
|
messageSource = messageSource
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加节点
|
||||||
|
*/
|
||||||
|
@PostMapping("/add")
|
||||||
|
fun addNode(@RequestBody request: AddRpcNodeRequest): ResponseEntity<ApiResponse<RpcNodeConfigDto>> {
|
||||||
|
return try {
|
||||||
|
if (request.providerType.isBlank()) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.paramError("服务商类型不能为空"))
|
||||||
|
}
|
||||||
|
if (request.name.isBlank()) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.paramError("节点名称不能为空"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = rpcNodeService.addNode(request)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { node ->
|
||||||
|
ResponseEntity.ok(ApiResponse.success(node.toDto()))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("添加 RPC 节点失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(
|
||||||
|
ErrorCode.SERVER_ERROR,
|
||||||
|
customMsg = "添加节点失败:${e.message}",
|
||||||
|
messageSource = messageSource
|
||||||
|
))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("添加 RPC 节点异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(
|
||||||
|
ErrorCode.SERVER_ERROR,
|
||||||
|
customMsg = "添加节点失败:${e.message}",
|
||||||
|
messageSource = messageSource
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新节点
|
||||||
|
*/
|
||||||
|
@PostMapping("/update")
|
||||||
|
fun updateNode(@RequestBody request: UpdateRpcNodeRequest): ResponseEntity<ApiResponse<RpcNodeConfigDto>> {
|
||||||
|
return try {
|
||||||
|
val result = rpcNodeService.updateNode(request)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { node ->
|
||||||
|
ResponseEntity.ok(ApiResponse.success(node.toDto()))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("更新 RPC 节点失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(
|
||||||
|
ErrorCode.SERVER_ERROR,
|
||||||
|
customMsg = "更新节点失败:${e.message}",
|
||||||
|
messageSource = messageSource
|
||||||
|
))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("更新 RPC 节点异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(
|
||||||
|
ErrorCode.SERVER_ERROR,
|
||||||
|
customMsg = "更新节点失败:${e.message}",
|
||||||
|
messageSource = messageSource
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除节点
|
||||||
|
*/
|
||||||
|
@PostMapping("/delete")
|
||||||
|
fun deleteNode(@RequestBody request: DeleteRpcNodeRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||||
|
return try {
|
||||||
|
val result = rpcNodeService.deleteNode(request.id)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = {
|
||||||
|
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("删除 RPC 节点失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(
|
||||||
|
ErrorCode.SERVER_ERROR,
|
||||||
|
customMsg = "删除节点失败:${e.message}",
|
||||||
|
messageSource = messageSource
|
||||||
|
))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("删除 RPC 节点异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(
|
||||||
|
ErrorCode.SERVER_ERROR,
|
||||||
|
customMsg = "删除节点失败:${e.message}",
|
||||||
|
messageSource = messageSource
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新节点优先级
|
||||||
|
*/
|
||||||
|
@PostMapping("/update-priority")
|
||||||
|
fun updatePriority(@RequestBody request: UpdatePriorityRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||||
|
return try {
|
||||||
|
val result = rpcNodeService.updatePriority(request.id, request.priority)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = {
|
||||||
|
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("更新节点优先级失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(
|
||||||
|
ErrorCode.SERVER_ERROR,
|
||||||
|
customMsg = "更新优先级失败:${e.message}",
|
||||||
|
messageSource = messageSource
|
||||||
|
))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("更新节点优先级异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(
|
||||||
|
ErrorCode.SERVER_ERROR,
|
||||||
|
customMsg = "更新优先级失败:${e.message}",
|
||||||
|
messageSource = messageSource
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查节点健康状态
|
||||||
|
*/
|
||||||
|
@PostMapping("/check-health")
|
||||||
|
fun checkHealth(@RequestBody request: CheckHealthRequest): ResponseEntity<ApiResponse<Any>> {
|
||||||
|
return try {
|
||||||
|
if (request.id != null) {
|
||||||
|
// 检查单个节点
|
||||||
|
val result = rpcNodeService.checkNodeHealth(request.id)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { checkResult ->
|
||||||
|
ResponseEntity.ok(ApiResponse.success(checkResult.toDto()))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("检查节点健康状态失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(
|
||||||
|
ErrorCode.SERVER_ERROR,
|
||||||
|
customMsg = "检查节点失败:${e.message}",
|
||||||
|
messageSource = messageSource
|
||||||
|
))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// 批量检查所有节点
|
||||||
|
val result = rpcNodeService.checkAllNodesHealth()
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { checkResults ->
|
||||||
|
val dtos = checkResults.mapValues { it.value.toDto() }
|
||||||
|
ResponseEntity.ok(ApiResponse.success(dtos))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("批量检查节点健康状态失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(
|
||||||
|
ErrorCode.SERVER_ERROR,
|
||||||
|
customMsg = "批量检查节点失败:${e.message}",
|
||||||
|
messageSource = messageSource
|
||||||
|
))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("检查节点健康状态异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(
|
||||||
|
ErrorCode.SERVER_ERROR,
|
||||||
|
customMsg = "检查节点失败:${e.message}",
|
||||||
|
messageSource = messageSource
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验节点(添加前)
|
||||||
|
*/
|
||||||
|
@PostMapping("/validate")
|
||||||
|
fun validateNode(@RequestBody request: AddRpcNodeRequest): ResponseEntity<ApiResponse<ValidateNodeResponse>> {
|
||||||
|
return try {
|
||||||
|
if (request.providerType.isBlank()) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.paramError("服务商类型不能为空"))
|
||||||
|
}
|
||||||
|
if (request.name.isBlank()) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.paramError("节点名称不能为空"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 临时创建节点配置以进行验证(不保存到数据库)
|
||||||
|
// 这里直接复用 addNode 的部分逻辑,但只进行校验
|
||||||
|
val result = rpcNodeService.addNode(request)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { node ->
|
||||||
|
// 添加成功后立即删除(这只是为了校验)
|
||||||
|
rpcNodeService.deleteNode(node.id!!)
|
||||||
|
ResponseEntity.ok(ApiResponse.success(ValidateNodeResponse(
|
||||||
|
valid = true,
|
||||||
|
message = "节点可用",
|
||||||
|
responseTimeMs = node.responseTimeMs
|
||||||
|
)))
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
ResponseEntity.ok(ApiResponse.success(ValidateNodeResponse(
|
||||||
|
valid = false,
|
||||||
|
message = e.message ?: "节点验证失败",
|
||||||
|
responseTimeMs = null
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("验证节点异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.success(ValidateNodeResponse(
|
||||||
|
valid = false,
|
||||||
|
message = e.message ?: "节点验证失败",
|
||||||
|
responseTimeMs = null
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 节点配置 DTO
|
||||||
|
*/
|
||||||
|
data class RpcNodeConfigDto(
|
||||||
|
val id: Long?,
|
||||||
|
val providerType: String,
|
||||||
|
val name: String,
|
||||||
|
val httpUrl: String,
|
||||||
|
val wsUrl: String?,
|
||||||
|
val apiKeyMasked: String?, // 脱敏后的 API Key
|
||||||
|
val enabled: Boolean,
|
||||||
|
val priority: Int,
|
||||||
|
val lastCheckTime: Long?,
|
||||||
|
val lastCheckStatus: String?,
|
||||||
|
val responseTimeMs: Int?,
|
||||||
|
val createdAt: Long,
|
||||||
|
val updatedAt: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点检查结果 DTO
|
||||||
|
*/
|
||||||
|
data class NodeCheckResultDto(
|
||||||
|
val status: String,
|
||||||
|
val message: String,
|
||||||
|
val checkTime: Long,
|
||||||
|
val responseTimeMs: Int?,
|
||||||
|
val blockNumber: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证节点响应
|
||||||
|
*/
|
||||||
|
data class ValidateNodeResponse(
|
||||||
|
val valid: Boolean,
|
||||||
|
val message: String,
|
||||||
|
val responseTimeMs: Int?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除节点请求
|
||||||
|
*/
|
||||||
|
data class DeleteRpcNodeRequest(
|
||||||
|
val id: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新优先级请求
|
||||||
|
*/
|
||||||
|
data class UpdatePriorityRequest(
|
||||||
|
val id: Long,
|
||||||
|
val priority: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查健康状态请求
|
||||||
|
*/
|
||||||
|
data class CheckHealthRequest(
|
||||||
|
val id: Long? = null // 如果为 null,则检查所有节点
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扩展函数:将 RpcNodeConfig 转换为 DTO
|
||||||
|
*/
|
||||||
|
private fun RpcNodeConfig.toDto(): RpcNodeConfigDto {
|
||||||
|
return RpcNodeConfigDto(
|
||||||
|
id = id,
|
||||||
|
providerType = providerType,
|
||||||
|
name = name,
|
||||||
|
httpUrl = httpUrl,
|
||||||
|
wsUrl = wsUrl,
|
||||||
|
apiKeyMasked = apiKey?.let { "***" }, // 脱敏显示
|
||||||
|
enabled = enabled,
|
||||||
|
priority = priority,
|
||||||
|
lastCheckTime = lastCheckTime,
|
||||||
|
lastCheckStatus = lastCheckStatus,
|
||||||
|
responseTimeMs = responseTimeMs,
|
||||||
|
createdAt = createdAt,
|
||||||
|
updatedAt = updatedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扩展函数:将 NodeCheckResult 转换为 DTO
|
||||||
|
*/
|
||||||
|
private fun NodeCheckResult.toDto(): NodeCheckResultDto {
|
||||||
|
return NodeCheckResultDto(
|
||||||
|
status = status.name,
|
||||||
|
message = message,
|
||||||
|
checkTime = checkTime,
|
||||||
|
responseTimeMs = responseTimeMs,
|
||||||
|
blockNumber = blockNumber
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,7 +7,39 @@ data class AccountImportRequest(
|
|||||||
val privateKey: String, // 私钥(前端加密后传输)
|
val privateKey: String, // 私钥(前端加密后传输)
|
||||||
val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证)
|
val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证)
|
||||||
val accountName: String? = null,
|
val accountName: String? = null,
|
||||||
val isEnabled: Boolean = true // 是否启用(用于订单推送等功能的开关)
|
val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关)
|
||||||
|
val walletType: String = "magic" // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查代理地址选项请求
|
||||||
|
*/
|
||||||
|
data class CheckProxyOptionsRequest(
|
||||||
|
val walletAddress: String, // EOA 地址(必需)
|
||||||
|
val privateKey: String? = null, // 私钥(加密,私钥导入时提供)
|
||||||
|
val mnemonic: String? = null // 助记词(加密,助记词导入时提供)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理地址选项信息
|
||||||
|
*/
|
||||||
|
data class ProxyOptionDto(
|
||||||
|
val walletType: String, // "magic" 或 "safe"
|
||||||
|
val proxyAddress: String, // 代理地址
|
||||||
|
val descriptionKey: String, // 说明文案的多语言 key(如 "accountImport.proxyOption.magic.description")
|
||||||
|
val availableBalance: String, // 可用余额
|
||||||
|
val positionBalance: String, // 仓位余额
|
||||||
|
val totalBalance: String, // 总余额
|
||||||
|
val positionCount: Int, // 持仓数量
|
||||||
|
val hasAssets: Boolean, // 是否有资产(余额>0 或持仓>0)
|
||||||
|
val error: String? = null // 获取失败时的错误信息(可选)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查代理地址选项响应
|
||||||
|
*/
|
||||||
|
data class CheckProxyOptionsResponse(
|
||||||
|
val options: List<ProxyOptionDto> // 代理地址选项列表(私钥导入返回2个,助记词返回1个)
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,9 +68,9 @@ data class SystemConfigDto(
|
|||||||
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
|
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
|
||||||
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
|
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
|
||||||
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
|
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
|
||||||
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(部分显示,用于前端展示)
|
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(完整,用于前端展示)
|
||||||
val builderSecretDisplay: String? = null, // Builder Secret 显示值(部分显示,用于前端展示)
|
val builderSecretDisplay: String? = null, // Builder Secret 显示值(完整,用于前端展示)
|
||||||
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(部分显示,用于前端展示)
|
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(完整,用于前端展示)
|
||||||
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
|
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -72,6 +104,7 @@ data class AccountDto(
|
|||||||
val proxyAddress: String, // Polymarket 代理钱包地址
|
val proxyAddress: String, // Polymarket 代理钱包地址
|
||||||
val accountName: String?,
|
val accountName: String?,
|
||||||
val isEnabled: Boolean, // 是否启用(用于订单推送等功能的开关)
|
val isEnabled: Boolean, // 是否启用(用于订单推送等功能的开关)
|
||||||
|
val walletType: String = "magic", // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)
|
||||||
val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key)
|
val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key)
|
||||||
val apiSecretConfigured: Boolean, // API Secret 是否已配置
|
val apiSecretConfigured: Boolean, // API Secret 是否已配置
|
||||||
val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置
|
val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置
|
||||||
@@ -91,6 +124,16 @@ data class AccountListResponse(
|
|||||||
val total: Long
|
val total: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钱包余额响应(通用类,用于 Account 和 Leader)
|
||||||
|
*/
|
||||||
|
data class WalletBalanceResponse(
|
||||||
|
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
|
||||||
|
val positionBalance: String, // 仓位余额(持仓总价值)
|
||||||
|
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
|
||||||
|
val positions: List<PositionDto> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 账户余额响应
|
* 账户余额响应
|
||||||
*/
|
*/
|
||||||
@@ -106,6 +149,7 @@ data class AccountBalanceResponse(
|
|||||||
*/
|
*/
|
||||||
data class PositionDto(
|
data class PositionDto(
|
||||||
val marketId: String,
|
val marketId: String,
|
||||||
|
val title: String?, // 市场名称
|
||||||
val side: String, // YES 或 NO
|
val side: String, // YES 或 NO
|
||||||
val quantity: String,
|
val quantity: String,
|
||||||
val avgPrice: String,
|
val avgPrice: String,
|
||||||
@@ -123,7 +167,8 @@ data class AccountPositionDto(
|
|||||||
val proxyAddress: String,
|
val proxyAddress: String,
|
||||||
val marketId: String,
|
val marketId: String,
|
||||||
val marketTitle: String?,
|
val marketTitle: String?,
|
||||||
val marketSlug: String?,
|
val marketSlug: String?, // 显示用的 slug
|
||||||
|
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
|
||||||
val marketIcon: String?, // 市场图标 URL
|
val marketIcon: String?, // 市场图标 URL
|
||||||
val side: String, // 结果名称(如 "YES", "NO", "Pakistan" 等)
|
val side: String, // 结果名称(如 "YES", "NO", "Pakistan" 等)
|
||||||
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2...),用于计算 tokenId
|
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2...),用于计算 tokenId
|
||||||
@@ -195,14 +240,11 @@ data class LatestPriceRequest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 市场价格响应
|
* 市场当前价格响应
|
||||||
*/
|
*/
|
||||||
data class MarketPriceResponse(
|
data class MarketPriceResponse(
|
||||||
val marketId: String,
|
val marketId: String,
|
||||||
val lastPrice: String?, // 最新成交价
|
val currentPrice: String // 当前价格(通过 MarketPriceService 获取,支持多数据源降级)
|
||||||
val bestBid: String?, // 最优买价(用于卖出参考)
|
|
||||||
val bestAsk: String?, // 最优卖价(用于买入参考)
|
|
||||||
val midpoint: String? // 中间价
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.wrbug.polymarketbot.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账户设置状态检查结果
|
||||||
|
*/
|
||||||
|
data class AccountSetupStatusDto(
|
||||||
|
/**
|
||||||
|
* 步骤1:代理钱包是否已部署
|
||||||
|
*/
|
||||||
|
val proxyDeployed: Boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 步骤2:交易是否已启用(API Key 是否已配置)
|
||||||
|
*/
|
||||||
|
val tradingEnabled: Boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 步骤3:代币是否已批准
|
||||||
|
*/
|
||||||
|
val tokensApproved: Boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代币批准详情(各合约的授权额度)
|
||||||
|
* Key: 合约名称(CTF_CONTRACT, CTF_EXCHANGE, NEG_RISK_EXCHANGE, NEG_RISK_ADAPTER)
|
||||||
|
* Value: 授权额度(USDC,6位小数)
|
||||||
|
*/
|
||||||
|
val approvalDetails: Map<String, String>? = null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查错误信息(如果有)
|
||||||
|
*/
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行设置步骤请求
|
||||||
|
*/
|
||||||
|
data class ExecuteSetupStepRequest(
|
||||||
|
/** 账户 ID */
|
||||||
|
val accountId: Long? = null,
|
||||||
|
/** 步骤:1=部署代理, 2=启用交易, 3=批准代币 */
|
||||||
|
val step: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行设置步骤响应
|
||||||
|
*/
|
||||||
|
data class ExecuteSetupStepResponse(
|
||||||
|
/** 是否由后端执行成功(步骤1 仅返回跳转链接,为 false) */
|
||||||
|
val success: Boolean = false,
|
||||||
|
/** 需跳转时由后端提供的 URL(步骤1 使用) */
|
||||||
|
val redirectUrl: String? = null,
|
||||||
|
/** 链上交易哈希(步骤3 批准代币成功时返回) */
|
||||||
|
val transactionHash: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账户导入响应(扩展,包含设置状态)
|
||||||
|
*/
|
||||||
|
data class AccountImportResponse(
|
||||||
|
val account: AccountDto,
|
||||||
|
val setupStatus: AccountSetupStatusDto? = null // 设置状态检查结果(可选)
|
||||||
|
)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.wrbug.polymarketbot.dto
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity WebSocket 交易消息 DTO
|
||||||
|
* 根据 Polymarket RTDS Activity WebSocket API 格式定义
|
||||||
|
*/
|
||||||
|
data class ActivityTradeMessage(
|
||||||
|
val topic: String = "", // "activity"
|
||||||
|
val type: String = "", // "trades"
|
||||||
|
val timestamp: Long? = null, // 消息时间戳(可选)
|
||||||
|
@SerializedName("connection_id")
|
||||||
|
val connectionId: String? = null, // 连接 ID(可选,由服务器返回)
|
||||||
|
val payload: ActivityTradePayload = ActivityTradePayload() // 交易数据
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity Trade Payload
|
||||||
|
*/
|
||||||
|
data class ActivityTradePayload(
|
||||||
|
val asset: String = "", // Token ID (用于下单)
|
||||||
|
|
||||||
|
@SerializedName("conditionId")
|
||||||
|
val conditionId: String = "", // Market condition ID
|
||||||
|
|
||||||
|
@SerializedName("eventSlug")
|
||||||
|
val eventSlug: String? = null, // 事件 slug
|
||||||
|
|
||||||
|
val slug: String? = null, // 市场 slug
|
||||||
|
|
||||||
|
val outcome: String? = null, // 结果方向 (Yes/No/Up/Down)
|
||||||
|
|
||||||
|
@SerializedName("outcomeIndex")
|
||||||
|
val outcomeIndex: Int? = null, // 结果索引 (0=Yes/Up, 1=No/Down) - 优先使用此字段
|
||||||
|
|
||||||
|
val side: String = "", // 交易方向 (BUY/SELL)
|
||||||
|
|
||||||
|
// price 和 size 可能是数字或字符串,使用 Any 类型,后续转换为 String
|
||||||
|
val price: Any? = null, // 交易价格
|
||||||
|
|
||||||
|
val size: Any? = null, // 交易数量 (shares)
|
||||||
|
|
||||||
|
val timestamp: Any? = null, // Unix 时间戳(可能是秒或毫秒,可能是数字或字符串)
|
||||||
|
|
||||||
|
@SerializedName("transactionHash")
|
||||||
|
val transactionHash: String? = null, // 交易哈希
|
||||||
|
|
||||||
|
val trader: ActivityTrader? = null, // 交易者信息对象(优先)
|
||||||
|
|
||||||
|
@SerializedName("proxyWallet")
|
||||||
|
val proxyWallet: String? = null, // 交易者地址(fallback,如果 trader 不存在)
|
||||||
|
|
||||||
|
val name: String? = null // 交易者名称(fallback,如果 trader 不存在)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity Trader 信息
|
||||||
|
*/
|
||||||
|
data class ActivityTrader(
|
||||||
|
val name: String? = null, // 交易者用户名(可选)
|
||||||
|
val address: String? = null // 交易者钱包地址 ⭐ 关键字段
|
||||||
|
)
|
||||||
|
|
||||||
@@ -14,3 +14,10 @@ data class CheckFirstUseResponse(
|
|||||||
val isFirstUse: Boolean
|
val isFirstUse: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket 票据响应
|
||||||
|
*/
|
||||||
|
data class WebSocketTicketResponse(
|
||||||
|
val ticket: String
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package com.wrbug.polymarketbot.dto
|
||||||
|
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测任务创建请求
|
||||||
|
*/
|
||||||
|
data class BacktestCreateRequest(
|
||||||
|
val taskName: String, // 回测任务名称
|
||||||
|
val leaderId: Long, // Leader ID
|
||||||
|
val initialBalance: String, // 初始资金
|
||||||
|
val backtestDays: Int, // 回测天数 (1-30)
|
||||||
|
// 跟单配置(与 CopyTrading 一致,但不包含 max_position_count)
|
||||||
|
val copyMode: String? = null, // "RATIO" 或 "FIXED"
|
||||||
|
val copyRatio: String? = null, // 仅在 copyMode="RATIO" 时生效
|
||||||
|
val fixedAmount: String? = null, // 仅在 copyMode="FIXED" 时生效
|
||||||
|
val maxOrderSize: String? = null,
|
||||||
|
val minOrderSize: String? = null,
|
||||||
|
val maxDailyLoss: String? = null,
|
||||||
|
val maxDailyOrders: Int? = null,
|
||||||
|
val supportSell: Boolean? = null,
|
||||||
|
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||||
|
val keywords: List<String>? = null, // 关键字列表
|
||||||
|
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||||
|
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||||
|
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
val pageForResume: Int? = null // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测任务列表请求
|
||||||
|
*/
|
||||||
|
data class BacktestListRequest(
|
||||||
|
val leaderId: Long? = null, // Leader ID(可选)
|
||||||
|
val status: String? = null, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
|
||||||
|
val sortBy: String? = null, // profitAmount / profitRate / createdAt
|
||||||
|
val sortOrder: String? = null, // asc / desc
|
||||||
|
val page: Int = 1, // 页码,从1开始
|
||||||
|
val size: Int = 20 // 每页数量
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测任务详情请求
|
||||||
|
*/
|
||||||
|
data class BacktestDetailRequest(
|
||||||
|
val id: Long // 回测任务ID
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测交易记录请求
|
||||||
|
*/
|
||||||
|
data class BacktestTradeListRequest(
|
||||||
|
val taskId: Long, // 回测任务ID
|
||||||
|
val page: Int = 1, // 页码,从1开始
|
||||||
|
val size: Int = 20 // 每页数量
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测进度查询请求
|
||||||
|
*/
|
||||||
|
data class BacktestProgressRequest(
|
||||||
|
val id: Long // 回测任务ID
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测任务停止请求
|
||||||
|
*/
|
||||||
|
data class BacktestStopRequest(
|
||||||
|
val id: Long // 回测任务ID
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测任务删除请求
|
||||||
|
*/
|
||||||
|
data class BacktestDeleteRequest(
|
||||||
|
val id: Long // 回测任务ID
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测任务重试请求
|
||||||
|
*/
|
||||||
|
data class BacktestRetryRequest(
|
||||||
|
val id: Long // 回测任务ID
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按当前配置重新测试请求(仅支持已完成任务)
|
||||||
|
*/
|
||||||
|
data class BacktestRerunRequest(
|
||||||
|
val id: Long, // 源回测任务ID
|
||||||
|
val taskName: String? = null // 新任务名称,为空时使用「原名称 (副本)」
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测任务列表响应
|
||||||
|
*/
|
||||||
|
data class BacktestListResponse(
|
||||||
|
val list: List<BacktestTaskDto>,
|
||||||
|
val total: Long,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测任务详情响应
|
||||||
|
*/
|
||||||
|
data class BacktestDetailResponse(
|
||||||
|
val task: BacktestTaskDto,
|
||||||
|
val config: BacktestConfigDto,
|
||||||
|
val statistics: BacktestStatisticsDto
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测交易记录列表响应
|
||||||
|
*/
|
||||||
|
data class BacktestTradeListResponse(
|
||||||
|
val list: List<BacktestTradeDto>,
|
||||||
|
val total: Long,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测进度响应
|
||||||
|
*/
|
||||||
|
data class BacktestProgressResponse(
|
||||||
|
val progress: Int, // 执行进度 (0-100)
|
||||||
|
val currentBalance: String, // 当前余额
|
||||||
|
val totalTrades: Int, // 总交易笔数
|
||||||
|
val status: String // 任务状态
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测任务 DTO
|
||||||
|
*/
|
||||||
|
data class BacktestTaskDto(
|
||||||
|
val id: Long,
|
||||||
|
val taskName: String,
|
||||||
|
val leaderId: Long,
|
||||||
|
val leaderName: String?,
|
||||||
|
val leaderAddress: String?,
|
||||||
|
val initialBalance: String,
|
||||||
|
val finalBalance: String?,
|
||||||
|
val profitAmount: String?,
|
||||||
|
val profitRate: String?,
|
||||||
|
val backtestDays: Int,
|
||||||
|
val startTime: Long,
|
||||||
|
val endTime: Long?,
|
||||||
|
val status: String, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
|
||||||
|
val progress: Int,
|
||||||
|
val totalTrades: Int,
|
||||||
|
val createdAt: Long,
|
||||||
|
val executionStartedAt: Long?,
|
||||||
|
val executionFinishedAt: Long?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测配置 DTO
|
||||||
|
*/
|
||||||
|
data class BacktestConfigDto(
|
||||||
|
val copyMode: String,
|
||||||
|
val copyRatio: String,
|
||||||
|
val fixedAmount: String?,
|
||||||
|
val maxOrderSize: String,
|
||||||
|
val minOrderSize: String,
|
||||||
|
val maxDailyLoss: String,
|
||||||
|
val maxDailyOrders: Int,
|
||||||
|
val supportSell: Boolean,
|
||||||
|
val keywordFilterMode: String?,
|
||||||
|
val keywords: List<String>?,
|
||||||
|
val maxPositionValue: String?,
|
||||||
|
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||||
|
val maxPrice: String? // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测统计信息 DTO
|
||||||
|
*/
|
||||||
|
data class BacktestStatisticsDto(
|
||||||
|
val totalTrades: Int, // 总交易笔数
|
||||||
|
val buyTrades: Int, // 买入笔数
|
||||||
|
val sellTrades: Int, // 卖出笔数
|
||||||
|
val winTrades: Int, // 盈利交易笔数
|
||||||
|
val lossTrades: Int, // 亏损交易笔数
|
||||||
|
val winRate: String, // 胜率(%)
|
||||||
|
val maxProfit: String, // 最大单笔盈利
|
||||||
|
val maxLoss: String, // 最大单笔亏损
|
||||||
|
val maxDrawdown: String, // 最大回撤
|
||||||
|
val avgHoldingTime: Long? // 平均持仓时间(毫秒)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测交易记录 DTO
|
||||||
|
*/
|
||||||
|
data class BacktestTradeDto(
|
||||||
|
val id: Long,
|
||||||
|
val tradeTime: Long,
|
||||||
|
val marketId: String,
|
||||||
|
val marketTitle: String?,
|
||||||
|
val side: String, // BUY/SELL/SETTLEMENT
|
||||||
|
val outcome: String,
|
||||||
|
val outcomeIndex: Int?,
|
||||||
|
val quantity: String,
|
||||||
|
val price: String,
|
||||||
|
val amount: String,
|
||||||
|
val fee: String,
|
||||||
|
val profitLoss: String?,
|
||||||
|
val balanceAfter: String,
|
||||||
|
val leaderTradeId: String?
|
||||||
|
)
|
||||||
|
|
||||||
@@ -34,9 +34,16 @@ data class CopyTradingCreateRequest(
|
|||||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
// 最大仓位配置
|
||||||
|
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||||
|
// 关键字过滤配置
|
||||||
|
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||||
|
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||||
// 新增配置字段
|
// 新增配置字段
|
||||||
val configName: String? = null, // 配置名(可选)
|
val configName: String? = null, // 配置名(可选)
|
||||||
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
|
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
|
||||||
|
val pushFilteredOrders: Boolean? = null, // 推送已过滤订单(可选)
|
||||||
|
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,9 +72,16 @@ data class CopyTradingUpdateRequest(
|
|||||||
val maxSpread: String? = null,
|
val maxSpread: String? = null,
|
||||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
// 最大仓位配置
|
||||||
|
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||||
|
// 关键字过滤配置
|
||||||
|
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||||
|
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||||
// 新增配置字段
|
// 新增配置字段
|
||||||
val configName: String? = null, // 配置名(可选,但提供时必须非空)
|
val configName: String? = null, // 配置名(可选,但提供时必须非空)
|
||||||
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
|
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
|
||||||
|
val pushFilteredOrders: Boolean? = null, // 推送已过滤订单(可选)
|
||||||
|
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,9 +147,16 @@ data class CopyTradingDto(
|
|||||||
val maxSpread: String?,
|
val maxSpread: String?,
|
||||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||||
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
// 最大仓位配置
|
||||||
|
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||||
|
// 关键字过滤配置
|
||||||
|
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||||
|
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||||
// 新增配置字段
|
// 新增配置字段
|
||||||
val configName: String? = null, // 配置名(可选)
|
val configName: String? = null, // 配置名(可选)
|
||||||
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
||||||
|
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
|
||||||
|
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val updatedAt: Long
|
val updatedAt: Long
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ data class BuyOrderInfo(
|
|||||||
val orderId: String,
|
val orderId: String,
|
||||||
val leaderTradeId: String,
|
val leaderTradeId: String,
|
||||||
val marketId: String,
|
val marketId: String,
|
||||||
|
val marketTitle: String? = null, // 市场名称
|
||||||
|
val marketSlug: String? = null, // 市场 slug(用于显示)
|
||||||
|
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
|
||||||
|
val marketCategory: String? = null, // 市场分类(sports, crypto 等)
|
||||||
val side: String,
|
val side: String,
|
||||||
val quantity: String,
|
val quantity: String,
|
||||||
val price: String,
|
val price: String,
|
||||||
@@ -57,6 +61,10 @@ data class SellOrderInfo(
|
|||||||
val orderId: String,
|
val orderId: String,
|
||||||
val leaderTradeId: String,
|
val leaderTradeId: String,
|
||||||
val marketId: String,
|
val marketId: String,
|
||||||
|
val marketTitle: String? = null, // 市场名称
|
||||||
|
val marketSlug: String? = null, // 市场 slug(用于显示)
|
||||||
|
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
|
||||||
|
val marketCategory: String? = null, // 市场分类(sports, crypto 等)
|
||||||
val side: String,
|
val side: String,
|
||||||
val quantity: String,
|
val quantity: String,
|
||||||
val price: String,
|
val price: String,
|
||||||
@@ -71,6 +79,11 @@ data class SellOrderInfo(
|
|||||||
data class MatchedOrderInfo(
|
data class MatchedOrderInfo(
|
||||||
val sellOrderId: String,
|
val sellOrderId: String,
|
||||||
val buyOrderId: String,
|
val buyOrderId: String,
|
||||||
|
val marketId: String? = null, // 市场ID(从买入订单获取)
|
||||||
|
val marketTitle: String? = null, // 市场名称
|
||||||
|
val marketSlug: String? = null, // 市场 slug(用于显示)
|
||||||
|
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
|
||||||
|
val marketCategory: String? = null, // 市场分类(sports, crypto 等)
|
||||||
val matchedQuantity: String,
|
val matchedQuantity: String,
|
||||||
val buyPrice: String,
|
val buyPrice: String,
|
||||||
val sellPrice: String,
|
val sellPrice: String,
|
||||||
@@ -97,12 +110,60 @@ data class OrderTrackingRequest(
|
|||||||
val page: Int? = 1,
|
val page: Int? = 1,
|
||||||
val limit: Int? = 20,
|
val limit: Int? = 20,
|
||||||
val marketId: String? = null,
|
val marketId: String? = null,
|
||||||
val side: String? = null,
|
val marketTitle: String? = null, // 市场标题关键字筛选
|
||||||
val status: String? = null,
|
val status: String? = null,
|
||||||
val sellOrderId: String? = null,
|
val sellOrderId: String? = null,
|
||||||
val buyOrderId: String? = null
|
val buyOrderId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按市场分组的订单查询请求
|
||||||
|
*/
|
||||||
|
data class MarketGroupedOrdersRequest(
|
||||||
|
val copyTradingId: Long,
|
||||||
|
val type: String, // buy, sell, matched
|
||||||
|
val page: Int? = 1,
|
||||||
|
val limit: Int? = 20,
|
||||||
|
val marketId: String? = null,
|
||||||
|
val marketTitle: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个市场的订单统计信息
|
||||||
|
*/
|
||||||
|
data class MarketOrderStats(
|
||||||
|
val count: Long,
|
||||||
|
val totalAmount: String, // 总金额
|
||||||
|
val totalPnl: String?, // 总盈亏(买入订单未实现盈亏,此字段为空)
|
||||||
|
val fullyMatched: Boolean, // 是否全部成交
|
||||||
|
val fullyMatchedCount: Long, // 完全成交的订单数
|
||||||
|
val partiallyMatchedCount: Long, // 部分成交的订单数
|
||||||
|
val filledCount: Long // 未成交的订单数
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个市场分组的响应数据
|
||||||
|
*/
|
||||||
|
data class MarketOrderGroup(
|
||||||
|
val marketId: String,
|
||||||
|
val marketTitle: String?,
|
||||||
|
val marketSlug: String?, // 显示用的 slug
|
||||||
|
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
|
||||||
|
val marketCategory: String?,
|
||||||
|
val stats: MarketOrderStats,
|
||||||
|
val orders: List<Any> // BuyOrderInfo, SellOrderInfo 或 MatchedOrderInfo 的列表
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按市场分组的订单列表响应
|
||||||
|
*/
|
||||||
|
data class MarketGroupedOrdersResponse(
|
||||||
|
val list: List<MarketOrderGroup>,
|
||||||
|
val total: Long, // 市场总数
|
||||||
|
val page: Int,
|
||||||
|
val limit: Int
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计查询请求
|
* 统计查询请求
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ data class TemplateCreateRequest(
|
|||||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +51,8 @@ data class TemplateUpdateRequest(
|
|||||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,7 +86,8 @@ data class TemplateCopyRequest(
|
|||||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,6 +122,7 @@ data class TemplateDto(
|
|||||||
val maxSpread: String?,
|
val maxSpread: String?,
|
||||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||||
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
val pushFilteredOrders: Boolean, // 推送已过滤订单(默认关闭)
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val updatedAt: Long
|
val updatedAt: Long
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.wrbug.polymarketbot.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控初始化请求
|
||||||
|
*/
|
||||||
|
data class CryptoTailMonitorInitRequest(
|
||||||
|
/** 策略ID */
|
||||||
|
val strategyId: Long = 0L,
|
||||||
|
/** 指定周期开始时间 (Unix 秒),不传则用服务器当前周期 */
|
||||||
|
val periodStartUnix: Long? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控初始化响应
|
||||||
|
*/
|
||||||
|
data class CryptoTailMonitorInitResponse(
|
||||||
|
/** 策略ID */
|
||||||
|
val strategyId: Long = 0L,
|
||||||
|
/** 策略名称 */
|
||||||
|
val name: String = "",
|
||||||
|
/** 账户ID */
|
||||||
|
val accountId: Long = 0L,
|
||||||
|
/** 账户名称 */
|
||||||
|
val accountName: String = "",
|
||||||
|
/** 市场 slug 前缀 */
|
||||||
|
val marketSlugPrefix: String = "",
|
||||||
|
/** 市场标题 */
|
||||||
|
val marketTitle: String = "",
|
||||||
|
/** 周期秒数 (300=5m, 900=15m) */
|
||||||
|
val intervalSeconds: Int = 300,
|
||||||
|
/** 当前周期开始时间 (Unix 秒) */
|
||||||
|
val periodStartUnix: Long = 0L,
|
||||||
|
/** 时间窗口开始秒数 */
|
||||||
|
val windowStartSeconds: Int = 0,
|
||||||
|
/** 时间窗口结束秒数 */
|
||||||
|
val windowEndSeconds: Int = 0,
|
||||||
|
/** 最低价格 */
|
||||||
|
val minPrice: String = "0",
|
||||||
|
/** 最高价格 */
|
||||||
|
val maxPrice: String = "1",
|
||||||
|
/** 最小价差模式: NONE, FIXED, AUTO */
|
||||||
|
val minSpreadMode: String = "NONE",
|
||||||
|
/** 价差方向: MIN(显示周期内最小价差), MAX(显示周期内最大价差) */
|
||||||
|
val spreadDirection: String = "MIN",
|
||||||
|
/** 最小价差数值 (FIXED 时有值) */
|
||||||
|
val minSpreadValue: String? = null,
|
||||||
|
/** 自动计算的最小价差 (Up方向) */
|
||||||
|
val autoMinSpreadUp: String? = null,
|
||||||
|
/** 自动计算的最小价差 (Down方向) */
|
||||||
|
val autoMinSpreadDown: String? = null,
|
||||||
|
/** BTC 开盘价 USDC(来自币安 K 线 open) */
|
||||||
|
val openPriceBtc: String? = null,
|
||||||
|
/** Up tokenId */
|
||||||
|
val tokenIdUp: String? = null,
|
||||||
|
/** Down tokenId */
|
||||||
|
val tokenIdDown: String? = null,
|
||||||
|
/** 当前时间 (毫秒时间戳) */
|
||||||
|
val currentTimestamp: Long = System.currentTimeMillis(),
|
||||||
|
/** 是否启用 */
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
/** 投入金额模式: FIXED or RATIO */
|
||||||
|
val amountMode: String? = null,
|
||||||
|
/** 投入金额数值 */
|
||||||
|
val amountValue: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控实时推送数据
|
||||||
|
*/
|
||||||
|
data class CryptoTailMonitorPushData(
|
||||||
|
/** 策略ID */
|
||||||
|
val strategyId: Long = 0L,
|
||||||
|
/** 推送时间 (毫秒时间戳) */
|
||||||
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
/** 当前周期开始时间 (Unix 秒) */
|
||||||
|
val periodStartUnix: Long = 0L,
|
||||||
|
/** 当前周期市场标题(周期切换时更新) */
|
||||||
|
val marketTitle: String = "",
|
||||||
|
/** 当前价格 (Up方向,来自订单簿) */
|
||||||
|
val currentPriceUp: String? = null,
|
||||||
|
/** 当前价格 (Down方向,来自订单簿) */
|
||||||
|
val currentPriceDown: String? = null,
|
||||||
|
/** 当前价差 (Up方向: 1 - currentPriceUp) */
|
||||||
|
val spreadUp: String? = null,
|
||||||
|
/** 当前价差 (Down方向: currentPriceUp) */
|
||||||
|
val spreadDown: String? = null,
|
||||||
|
/** 最小价差线 (Up方向) */
|
||||||
|
val minSpreadLineUp: String? = null,
|
||||||
|
/** 最小价差线 (Down方向,USDC 价差) */
|
||||||
|
val minSpreadLineDown: String? = null,
|
||||||
|
/** BTC 开盘价 USDC(币安 K 线 open) */
|
||||||
|
val openPriceBtc: String? = null,
|
||||||
|
/** BTC 最新价 USDC(币安 K 线 close,当前周期实时) */
|
||||||
|
val currentPriceBtc: String? = null,
|
||||||
|
/** BTC 价差 USDC(currentPriceBtc - openPriceBtc) */
|
||||||
|
val spreadBtc: String? = null,
|
||||||
|
/** 周期剩余秒数 */
|
||||||
|
val remainingSeconds: Int = 0,
|
||||||
|
/** 是否在时间窗口内 */
|
||||||
|
val inTimeWindow: Boolean = false,
|
||||||
|
/** 是否在价格区间内 (Up方向) */
|
||||||
|
val inPriceRangeUp: Boolean = false,
|
||||||
|
/** 是否在价格区间内 (Down方向) */
|
||||||
|
val inPriceRangeDown: Boolean = false,
|
||||||
|
/** 是否已触发 */
|
||||||
|
val triggered: Boolean = false,
|
||||||
|
/** 触发方向: UP, DOWN, null */
|
||||||
|
val triggerDirection: String? = null,
|
||||||
|
/** 周期是否已结束 */
|
||||||
|
val periodEnded: Boolean = false
|
||||||
|
)
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package com.wrbug.polymarketbot.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略创建请求
|
||||||
|
* 金额与价格使用 String,后端转为 BigDecimal
|
||||||
|
*/
|
||||||
|
data class CryptoTailStrategyCreateRequest(
|
||||||
|
val accountId: Long = 0L,
|
||||||
|
val name: String? = null,
|
||||||
|
val marketSlugPrefix: String = "",
|
||||||
|
val intervalSeconds: Int = 300,
|
||||||
|
val windowStartSeconds: Int = 0,
|
||||||
|
val windowEndSeconds: Int = 0,
|
||||||
|
val minPrice: String = "0",
|
||||||
|
val maxPrice: String? = null,
|
||||||
|
val amountMode: String = "RATIO",
|
||||||
|
val amountValue: String = "0",
|
||||||
|
/** 价差模式: NONE, FIXED, AUTO */
|
||||||
|
val spreadMode: String = "NONE",
|
||||||
|
/** 价差数值 */
|
||||||
|
val spreadValue: String? = null,
|
||||||
|
/** 价差方向: MIN=最小价差, MAX=最大价差 */
|
||||||
|
val spreadDirection: String = "MIN",
|
||||||
|
val enabled: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略更新请求
|
||||||
|
*/
|
||||||
|
data class CryptoTailStrategyUpdateRequest(
|
||||||
|
val strategyId: Long = 0L,
|
||||||
|
val name: String? = null,
|
||||||
|
val windowStartSeconds: Int? = null,
|
||||||
|
val windowEndSeconds: Int? = null,
|
||||||
|
val minPrice: String? = null,
|
||||||
|
val maxPrice: String? = null,
|
||||||
|
val amountMode: String? = null,
|
||||||
|
val amountValue: String? = null,
|
||||||
|
/** 价差模式: NONE, FIXED, AUTO */
|
||||||
|
val spreadMode: String? = null,
|
||||||
|
/** 价差数值 */
|
||||||
|
val spreadValue: String? = null,
|
||||||
|
/** 价差方向: MIN=最小价差, MAX=最大价差 */
|
||||||
|
val spreadDirection: String? = null,
|
||||||
|
val enabled: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略列表请求
|
||||||
|
*/
|
||||||
|
data class CryptoTailStrategyListRequest(
|
||||||
|
val accountId: Long? = null,
|
||||||
|
val enabled: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略 DTO(列表与详情)
|
||||||
|
*/
|
||||||
|
data class CryptoTailStrategyDto(
|
||||||
|
val id: Long = 0L,
|
||||||
|
val accountId: Long = 0L,
|
||||||
|
val name: String? = null,
|
||||||
|
val marketSlugPrefix: String = "",
|
||||||
|
val marketTitle: String? = null,
|
||||||
|
val intervalSeconds: Int = 0,
|
||||||
|
val windowStartSeconds: Int = 0,
|
||||||
|
val windowEndSeconds: Int = 0,
|
||||||
|
val minPrice: String = "0",
|
||||||
|
val maxPrice: String = "1",
|
||||||
|
val amountMode: String = "RATIO",
|
||||||
|
val amountValue: String = "0",
|
||||||
|
/** 价差模式: NONE, FIXED, AUTO */
|
||||||
|
val spreadMode: String = "NONE",
|
||||||
|
/** 价差数值 */
|
||||||
|
val spreadValue: String? = null,
|
||||||
|
/** 价差方向: MIN=最小价差(价差>=配置值触发), MAX=最大价差(价差<=配置值触发) */
|
||||||
|
val spreadDirection: String = "MIN",
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val lastTriggerAt: Long? = null,
|
||||||
|
/** 已实现总收益 USDC(已结算订单的 realizedPnl 之和) */
|
||||||
|
val totalRealizedPnl: String? = null,
|
||||||
|
/** 已结算笔数(用于胜率分母) */
|
||||||
|
val settledCount: Long = 0L,
|
||||||
|
/** 已结算中赢的笔数(用于胜率分子) */
|
||||||
|
val winCount: Long = 0L,
|
||||||
|
/** 胜率 0~1(已结算时 = winCount/settledCount,无结算为 null) */
|
||||||
|
val winRate: String? = null,
|
||||||
|
val createdAt: Long = 0L,
|
||||||
|
val updatedAt: Long = 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略列表响应
|
||||||
|
*/
|
||||||
|
data class CryptoTailStrategyListResponse(
|
||||||
|
val list: List<CryptoTailStrategyDto> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略删除请求
|
||||||
|
*/
|
||||||
|
data class CryptoTailStrategyDeleteRequest(
|
||||||
|
val strategyId: Long = 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发记录列表请求
|
||||||
|
* @param startDate 开始日期(当天 00:00:00.000 的时间戳毫秒),为 null 表示不限制
|
||||||
|
* @param endDate 结束日期(当天 23:59:59.999 的时间戳毫秒),为 null 表示不限制
|
||||||
|
*/
|
||||||
|
data class CryptoTailStrategyTriggerListRequest(
|
||||||
|
val strategyId: Long = 0L,
|
||||||
|
val page: Int = 1,
|
||||||
|
val pageSize: Int = 20,
|
||||||
|
val status: String? = null,
|
||||||
|
val startDate: Long? = null,
|
||||||
|
val endDate: Long? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发记录 DTO
|
||||||
|
*/
|
||||||
|
data class CryptoTailStrategyTriggerDto(
|
||||||
|
val id: Long = 0L,
|
||||||
|
val strategyId: Long = 0L,
|
||||||
|
val periodStartUnix: Long = 0L,
|
||||||
|
val marketTitle: String? = null,
|
||||||
|
val outcomeIndex: Int = 0,
|
||||||
|
val triggerPrice: String = "0",
|
||||||
|
val amountUsdc: String = "0",
|
||||||
|
val orderId: String? = null,
|
||||||
|
val status: String = "success",
|
||||||
|
val failReason: String? = null,
|
||||||
|
/** 是否已结算 */
|
||||||
|
val resolved: Boolean = false,
|
||||||
|
/** 已实现盈亏 USDC(结算后有值) */
|
||||||
|
val realizedPnl: String? = null,
|
||||||
|
/** 市场赢家 outcome 索引(结算后有值) */
|
||||||
|
val winnerOutcomeIndex: Int? = null,
|
||||||
|
val settledAt: Long? = null,
|
||||||
|
val createdAt: Long = 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发记录分页响应
|
||||||
|
*/
|
||||||
|
data class CryptoTailStrategyTriggerListResponse(
|
||||||
|
val list: List<CryptoTailStrategyTriggerDto> = emptyList(),
|
||||||
|
val total: Long = 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7)
|
||||||
|
*/
|
||||||
|
data class CryptoTailAutoMinSpreadResponse(
|
||||||
|
val minSpreadUp: String = "0",
|
||||||
|
val minSpreadDown: String = "0"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5/15 分钟市场项(供前端选择市场)
|
||||||
|
*/
|
||||||
|
data class CryptoTailMarketOptionDto(
|
||||||
|
val slug: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val intervalSeconds: Int = 0,
|
||||||
|
val periodStartUnix: Long = 0L,
|
||||||
|
val endDate: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收益曲线请求
|
||||||
|
* @param strategyId 策略ID
|
||||||
|
* @param startDate 开始时间(毫秒时间戳),null 表示不限制
|
||||||
|
* @param endDate 结束时间(毫秒时间戳),null 表示不限制
|
||||||
|
*/
|
||||||
|
data class CryptoTailPnlCurveRequest(
|
||||||
|
val strategyId: Long = 0L,
|
||||||
|
val startDate: Long? = null,
|
||||||
|
val endDate: Long? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收益曲线单点数据
|
||||||
|
*/
|
||||||
|
data class CryptoTailPnlCurvePoint(
|
||||||
|
/** 时间点(毫秒时间戳,结算时间或创建时间) */
|
||||||
|
val timestamp: Long = 0L,
|
||||||
|
/** 累计收益 USDC */
|
||||||
|
val cumulativePnl: String = "0",
|
||||||
|
/** 当笔收益 USDC */
|
||||||
|
val pointPnl: String = "0",
|
||||||
|
/** 截至该点累计已结算笔数 */
|
||||||
|
val settledCount: Long = 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收益曲线响应
|
||||||
|
*/
|
||||||
|
data class CryptoTailPnlCurveResponse(
|
||||||
|
val strategyId: Long = 0L,
|
||||||
|
val strategyName: String = "",
|
||||||
|
/** 筛选范围内总已实现收益 USDC */
|
||||||
|
val totalRealizedPnl: String = "0",
|
||||||
|
val settledCount: Long = 0L,
|
||||||
|
val winCount: Long = 0L,
|
||||||
|
val winRate: String? = null,
|
||||||
|
/** 最大回撤 USDC(正数表示回撤幅度) */
|
||||||
|
val maxDrawdown: String? = null,
|
||||||
|
val curveData: List<CryptoTailPnlCurvePoint> = emptyList()
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.wrbug.polymarketbot.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket Geoblock API 原始响应
|
||||||
|
*/
|
||||||
|
data class PolymarketGeoblockApiResponse(
|
||||||
|
val blocked: Boolean = false,
|
||||||
|
val ip: String = "",
|
||||||
|
val country: String = "",
|
||||||
|
val region: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地域限制检查结果(返回给前端)
|
||||||
|
*/
|
||||||
|
data class GeoblockCheckDto(
|
||||||
|
val blocked: Boolean,
|
||||||
|
val ip: String,
|
||||||
|
val country: String,
|
||||||
|
val region: String,
|
||||||
|
val checkedAt: Long,
|
||||||
|
val source: String = "server"
|
||||||
|
)
|
||||||
@@ -36,6 +36,13 @@ data class LeaderListRequest(
|
|||||||
val category: String? = null // sports 或 crypto
|
val category: String? = null // sports 或 crypto
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leader 余额请求
|
||||||
|
*/
|
||||||
|
data class LeaderBalanceRequest(
|
||||||
|
val leaderId: Long // LeaderID(必需)
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leader 信息响应
|
* Leader 信息响应
|
||||||
*/
|
*/
|
||||||
@@ -47,6 +54,7 @@ data class LeaderDto(
|
|||||||
val remark: String? = null, // Leader 备注(可选)
|
val remark: String? = null, // Leader 备注(可选)
|
||||||
val website: String? = null, // Leader 网站(可选)
|
val website: String? = null, // Leader 网站(可选)
|
||||||
val copyTradingCount: Long = 0, // 跟单关系数量
|
val copyTradingCount: Long = 0, // 跟单关系数量
|
||||||
|
val backtestCount: Long = 0, // 回测数量
|
||||||
val totalOrders: Long? = null, // 总订单数(可选)
|
val totalOrders: Long? = null, // 总订单数(可选)
|
||||||
val totalPnl: String? = null, // 总盈亏(可选)
|
val totalPnl: String? = null, // 总盈亏(可选)
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
@@ -61,3 +69,16 @@ data class LeaderListResponse(
|
|||||||
val total: Long
|
val total: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leader 余额响应
|
||||||
|
*/
|
||||||
|
data class LeaderBalanceResponse(
|
||||||
|
val leaderId: Long,
|
||||||
|
val leaderAddress: String,
|
||||||
|
val leaderName: String?,
|
||||||
|
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
|
||||||
|
val positionBalance: String, // 仓位余额(持仓总价值)
|
||||||
|
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
|
||||||
|
val positions: List<PositionDto> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ data class MarketDto(
|
|||||||
val liquidityNum: Double?,
|
val liquidityNum: Double?,
|
||||||
val bestBid: Double?,
|
val bestBid: Double?,
|
||||||
val bestAsk: Double?,
|
val bestAsk: Double?,
|
||||||
val lastTradePrice: Double?
|
val lastTradePrice: Double?,
|
||||||
|
val events: List<MarketDto>? = null // 事件列表(从 events[0] 获取 slug)
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,9 +38,9 @@ data class OutcomeDto(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 事件 DTO
|
* 事件 DTO(用于其他 API 调用)
|
||||||
*/
|
*/
|
||||||
data class EventDto(
|
data class EventListDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val category: String,
|
val category: String,
|
||||||
@@ -55,7 +56,7 @@ data class SeriesDto(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val category: String,
|
val category: String,
|
||||||
val events: List<EventDto>?,
|
val events: List<EventListDto>?,
|
||||||
val createdAt: Long? // 时间戳(毫秒)
|
val createdAt: Long? // 时间戳(毫秒)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,4 +71,3 @@ data class CommentDto(
|
|||||||
val createdAt: Long, // 时间戳(毫秒)
|
val createdAt: Long, // 时间戳(毫秒)
|
||||||
val user: String?
|
val user: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.wrbug.polymarketbot.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息模板 DTO
|
||||||
|
*/
|
||||||
|
data class NotificationTemplateDto(
|
||||||
|
val id: Long? = null,
|
||||||
|
val templateType: String, // 模板类型
|
||||||
|
val templateContent: String, // 模板内容
|
||||||
|
val isDefault: Boolean = false, // 是否使用默认模板
|
||||||
|
val createdAt: Long? = null,
|
||||||
|
val updatedAt: Long? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板变量 DTO
|
||||||
|
*/
|
||||||
|
data class TemplateVariableDto(
|
||||||
|
val key: String, // 变量名,如 account_name
|
||||||
|
val category: String, // 分类:common, order, copy_trading, redeem, error
|
||||||
|
val sortOrder: Int = 0 // 排序顺序
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板变量分类 DTO
|
||||||
|
*/
|
||||||
|
data class TemplateVariableCategoryDto(
|
||||||
|
val key: String, // 分类 key
|
||||||
|
val sortOrder: Int = 0 // 排序顺序
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板变量列表响应
|
||||||
|
*/
|
||||||
|
data class TemplateVariablesResponse(
|
||||||
|
val templateType: String, // 模板类型
|
||||||
|
val categories: List<TemplateVariableCategoryDto>, // 分类列表
|
||||||
|
val variables: List<TemplateVariableDto> // 变量列表
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新模板请求
|
||||||
|
*/
|
||||||
|
data class UpdateTemplateRequest(
|
||||||
|
val templateContent: String // 模板内容
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试模板请求
|
||||||
|
*/
|
||||||
|
data class TestTemplateRequest(
|
||||||
|
val templateType: String, // 模板类型
|
||||||
|
val templateContent: String? = null // 可选,如果不提供则使用已保存的模板
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板类型信息
|
||||||
|
*/
|
||||||
|
data class TemplateTypeInfoDto(
|
||||||
|
val type: String, // 模板类型
|
||||||
|
val name: String, // 类型名称
|
||||||
|
val description: String // 类型描述
|
||||||
|
)
|
||||||
@@ -51,18 +51,21 @@ data class OrderPushMessage(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 订单详情(通过 API 获取)
|
* 订单详情(通过 API 获取)
|
||||||
|
* @param price 订单限价(用户提交的买入/卖出价)
|
||||||
|
* @param avgFilledPrice 实际成交价 = original_size * price / size_matched(有成交时优先用于推送展示)
|
||||||
*/
|
*/
|
||||||
data class OrderDetailDto(
|
data class OrderDetailDto(
|
||||||
val id: String, // 订单 ID
|
val id: String, // 订单 ID
|
||||||
val market: String, // 市场 ID (condition ID)
|
val market: String, // 市场 ID (condition ID)
|
||||||
val side: String, // BUY/SELL
|
val side: String, // BUY/SELL
|
||||||
val price: String, // 价格
|
val price: String, // 订单限价
|
||||||
val size: String, // 订单大小
|
val size: String, // 订单大小
|
||||||
val filled: String, // 已成交数量
|
val filled: String, // 已成交数量
|
||||||
val status: String, // 订单状态
|
val status: String, // 订单状态
|
||||||
val createdAt: String, // 创建时间(ISO 8601 格式)
|
val createdAt: String, // 创建时间(ISO 8601 格式)
|
||||||
val marketName: String? = null, // 市场名称(通过 Data API 获取)
|
val marketName: String? = null, // 市场名称(通过 Data API 获取)
|
||||||
val marketSlug: String? = null, // 市场 slug
|
val marketSlug: String? = null, // 市场 slug
|
||||||
val marketIcon: String? = null // 市场图标
|
val marketIcon: String? = null, // 市场图标
|
||||||
|
val avgFilledPrice: String? = null // 实际成交价 = original_size*price/size_matched(有成交时使用)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ data class SubscriptionProxyConfigRequest(
|
|||||||
val type: String // CLASH 或 SS
|
val type: String // CLASH 或 SS
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理检查时的 Polymarket 地域限制检测结果
|
||||||
|
*/
|
||||||
|
data class ProxyCheckGeoblockResult(
|
||||||
|
val checked: Boolean,
|
||||||
|
val blocked: Boolean? = null,
|
||||||
|
val ip: String? = null,
|
||||||
|
val country: String? = null,
|
||||||
|
val region: String? = null,
|
||||||
|
val message: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 代理检查响应
|
* 代理检查响应
|
||||||
*/
|
*/
|
||||||
@@ -43,15 +55,22 @@ data class ProxyCheckResponse(
|
|||||||
val success: Boolean,
|
val success: Boolean,
|
||||||
val message: String,
|
val message: String,
|
||||||
val responseTime: Long? = null, // 响应时间(毫秒)
|
val responseTime: Long? = null, // 响应时间(毫秒)
|
||||||
val latency: Long? = null // 延迟(毫秒),与 responseTime 相同,用于前端显示
|
val latency: Long? = null, // 延迟(毫秒),与 responseTime 相同,用于前端显示
|
||||||
|
val geoblock: ProxyCheckGeoblockResult? = null
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun create(success: Boolean, message: String, responseTime: Long? = null): ProxyCheckResponse {
|
fun create(
|
||||||
|
success: Boolean,
|
||||||
|
message: String,
|
||||||
|
responseTime: Long? = null,
|
||||||
|
geoblock: ProxyCheckGeoblockResult? = null
|
||||||
|
): ProxyCheckResponse {
|
||||||
return ProxyCheckResponse(
|
return ProxyCheckResponse(
|
||||||
success = success,
|
success = success,
|
||||||
message = message,
|
message = message,
|
||||||
responseTime = responseTime,
|
responseTime = responseTime,
|
||||||
latency = responseTime // latency 和 responseTime 相同
|
latency = responseTime,
|
||||||
|
geoblock = geoblock
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.wrbug.polymarketbot.dto
|
||||||
|
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户交易数据
|
||||||
|
* 用于回测功能,从 Polymarket API 获取的用户交易历史
|
||||||
|
*/
|
||||||
|
data class TradeData(
|
||||||
|
val tradeId: String, // 交易 ID
|
||||||
|
val marketId: String, // 市场 ID
|
||||||
|
val marketTitle: String?, // 市场标题
|
||||||
|
val marketSlug: String?, // 市场 Slug
|
||||||
|
val side: String, // 交易方向: BUY/SELL
|
||||||
|
val outcome: String, // 结果: YES/NO 或 outcomeIndex
|
||||||
|
val outcomeIndex: Int?, // 结果索引
|
||||||
|
val price: BigDecimal, // 成交价格
|
||||||
|
val size: BigDecimal, // 成交数量
|
||||||
|
val amount: BigDecimal, // 成交金额
|
||||||
|
val timestamp: Long // 交易时间戳
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is TradeData) return false
|
||||||
|
return tradeId == other.tradeId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return tradeId.hashCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -16,11 +16,11 @@ data class Account(
|
|||||||
@Column(name = "private_key", nullable = false, length = 500)
|
@Column(name = "private_key", nullable = false, length = 500)
|
||||||
val privateKey: String, // 私钥(AES 加密存储)
|
val privateKey: String, // 私钥(AES 加密存储)
|
||||||
|
|
||||||
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
|
@Column(name = "wallet_address", nullable = false, length = 42)
|
||||||
val walletAddress: String, // 钱包地址(从私钥推导)
|
val walletAddress: String, // 钱包地址(从私钥推导),同一 EOA 可有多个账户(不同代理类型)
|
||||||
|
|
||||||
@Column(name = "proxy_address", nullable = false, length = 42)
|
@Column(name = "proxy_address", unique = true, nullable = false, length = 42)
|
||||||
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
|
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须),唯一
|
||||||
|
|
||||||
@Column(name = "api_key", length = 500)
|
@Column(name = "api_key", length = 500)
|
||||||
val apiKey: String? = null, // Polymarket API Key(可选,明文存储)
|
val apiKey: String? = null, // Polymarket API Key(可选,明文存储)
|
||||||
@@ -40,6 +40,9 @@ data class Account(
|
|||||||
@Column(name = "is_enabled", nullable = false)
|
@Column(name = "is_enabled", nullable = false)
|
||||||
val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关)
|
val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关)
|
||||||
|
|
||||||
|
@Column(name = "wallet_type", nullable = false, length = 20)
|
||||||
|
val walletType: String = "magic", // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.wrbug.polymarketbot.entity
|
||||||
|
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测任务实体
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "backtest_task")
|
||||||
|
data class BacktestTask(
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
val id: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "task_name", nullable = false, length = 100)
|
||||||
|
val taskName: String,
|
||||||
|
|
||||||
|
@Column(name = "leader_id", nullable = false)
|
||||||
|
val leaderId: Long,
|
||||||
|
|
||||||
|
// 回测参数
|
||||||
|
@Column(name = "initial_balance", nullable = false, precision = 20, scale = 8)
|
||||||
|
val initialBalance: BigDecimal,
|
||||||
|
|
||||||
|
@Column(name = "final_balance", precision = 20, scale = 8)
|
||||||
|
var finalBalance: BigDecimal? = null,
|
||||||
|
|
||||||
|
@Column(name = "profit_amount", precision = 20, scale = 8)
|
||||||
|
var profitAmount: BigDecimal? = null,
|
||||||
|
|
||||||
|
@Column(name = "profit_rate", precision = 10, scale = 4)
|
||||||
|
var profitRate: BigDecimal? = null, // 收益率(%)
|
||||||
|
|
||||||
|
@Column(name = "backtest_days", nullable = false)
|
||||||
|
val backtestDays: Int,
|
||||||
|
|
||||||
|
@Column(name = "start_time", nullable = false)
|
||||||
|
val startTime: Long, // 回测开始时间(历史时间),创建时计算;执行时以当前时间为基准用局部变量重算窗口
|
||||||
|
|
||||||
|
@Column(name = "end_time")
|
||||||
|
var endTime: Long? = null, // 回测结束时间(历史时间)
|
||||||
|
|
||||||
|
// 跟单配置 (复制CopyTrading表结构,但不包含 max_position_count)
|
||||||
|
@Column(name = "copy_mode", nullable = false, length = 10)
|
||||||
|
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
|
||||||
|
|
||||||
|
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
|
||||||
|
val copyRatio: BigDecimal = BigDecimal.ONE,
|
||||||
|
|
||||||
|
@Column(name = "fixed_amount", precision = 20, scale = 8)
|
||||||
|
val fixedAmount: BigDecimal? = null,
|
||||||
|
|
||||||
|
@Column(name = "max_order_size", nullable = false, precision = 20, scale = 8)
|
||||||
|
val maxOrderSize: BigDecimal = "1000".toSafeBigDecimal(),
|
||||||
|
|
||||||
|
@Column(name = "min_order_size", nullable = false, precision = 20, scale = 8)
|
||||||
|
val minOrderSize: BigDecimal = "1".toSafeBigDecimal(),
|
||||||
|
|
||||||
|
@Column(name = "max_daily_loss", nullable = false, precision = 20, scale = 8)
|
||||||
|
val maxDailyLoss: BigDecimal = "10000".toSafeBigDecimal(),
|
||||||
|
|
||||||
|
@Column(name = "max_daily_orders", nullable = false)
|
||||||
|
val maxDailyOrders: Int = 100,
|
||||||
|
|
||||||
|
@Column(name = "support_sell", nullable = false)
|
||||||
|
val supportSell: Boolean = true,
|
||||||
|
|
||||||
|
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
|
||||||
|
val keywordFilterMode: String = "DISABLED", // DISABLED/WHITELIST/BLACKLIST
|
||||||
|
|
||||||
|
@Column(name = "keywords", columnDefinition = "JSON")
|
||||||
|
val keywords: String? = null,
|
||||||
|
|
||||||
|
@Column(name = "max_position_value", precision = 20, scale = 8)
|
||||||
|
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||||
|
|
||||||
|
@Column(name = "min_price", precision = 20, scale = 8)
|
||||||
|
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||||
|
|
||||||
|
@Column(name = "max_price", precision = 20, scale = 8)
|
||||||
|
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
|
||||||
|
// 统计字段
|
||||||
|
@Column(name = "avg_holding_time")
|
||||||
|
var avgHoldingTime: Long? = null, // 平均持仓时间(毫秒)
|
||||||
|
|
||||||
|
@Column(name = "data_source", length = 50)
|
||||||
|
var dataSource: String = "MIXED", // INTERNAL/API/MIXED
|
||||||
|
|
||||||
|
// 执行状态
|
||||||
|
@Column(name = "status", nullable = false, length = 20)
|
||||||
|
var status: String = "PENDING", // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
|
||||||
|
|
||||||
|
@Column(name = "progress", nullable = false)
|
||||||
|
var progress: Int = 0, // 执行进度(0-100)
|
||||||
|
|
||||||
|
@Column(name = "total_trades", nullable = false)
|
||||||
|
var totalTrades: Int = 0,
|
||||||
|
|
||||||
|
@Column(name = "buy_trades", nullable = false)
|
||||||
|
var buyTrades: Int = 0,
|
||||||
|
|
||||||
|
@Column(name = "sell_trades", nullable = false)
|
||||||
|
var sellTrades: Int = 0,
|
||||||
|
|
||||||
|
@Column(name = "win_trades", nullable = false)
|
||||||
|
var winTrades: Int = 0,
|
||||||
|
|
||||||
|
@Column(name = "loss_trades", nullable = false)
|
||||||
|
var lossTrades: Int = 0,
|
||||||
|
|
||||||
|
@Column(name = "win_rate", precision = 5, scale = 2)
|
||||||
|
var winRate: BigDecimal? = null, // 胜率(%)
|
||||||
|
|
||||||
|
@Column(name = "max_profit", precision = 20, scale = 8)
|
||||||
|
var maxProfit: BigDecimal? = null, // 最大单笔盈利
|
||||||
|
|
||||||
|
@Column(name = "max_loss", precision = 20, scale = 8)
|
||||||
|
var maxLoss: BigDecimal? = null, // 最大单笔亏损
|
||||||
|
|
||||||
|
@Column(name = "max_drawdown", precision = 20, scale = 8)
|
||||||
|
var maxDrawdown: BigDecimal? = null, // 最大回撤
|
||||||
|
|
||||||
|
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||||
|
var errorMessage: String? = null,
|
||||||
|
|
||||||
|
// 时间字段
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
@Column(name = "execution_started_at")
|
||||||
|
var executionStartedAt: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "execution_finished_at")
|
||||||
|
var executionFinishedAt: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
var updatedAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
@Column(name = "last_processed_trade_time")
|
||||||
|
var lastProcessedTradeTime: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "last_processed_trade_index")
|
||||||
|
var lastProcessedTradeIndex: Int? = null,
|
||||||
|
|
||||||
|
@Column(name = "processed_trade_count")
|
||||||
|
var processedTradeCount: Int = 0
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.wrbug.polymarketbot.entity
|
||||||
|
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测交易记录实体
|
||||||
|
* 用于记录回测过程中的每笔模拟交易
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "backtest_trade")
|
||||||
|
data class BacktestTrade(
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
val id: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "backtest_task_id", nullable = false)
|
||||||
|
val backtestTaskId: Long,
|
||||||
|
|
||||||
|
@Column(name = "trade_time", nullable = false)
|
||||||
|
val tradeTime: Long,
|
||||||
|
|
||||||
|
@Column(name = "market_id", nullable = false, length = 100)
|
||||||
|
val marketId: String,
|
||||||
|
|
||||||
|
@Column(name = "market_title", length = 500)
|
||||||
|
val marketTitle: String? = null,
|
||||||
|
|
||||||
|
@Column(name = "side", nullable = false, length = 20)
|
||||||
|
val side: String, // BUY/SELL/SETTLEMENT
|
||||||
|
|
||||||
|
@Column(name = "outcome", nullable = false, length = 50)
|
||||||
|
val outcome: String, // YES/NO 或 outcomeIndex
|
||||||
|
|
||||||
|
@Column(name = "outcome_index")
|
||||||
|
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2, ...),支持多元市场
|
||||||
|
|
||||||
|
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
|
||||||
|
val quantity: BigDecimal,
|
||||||
|
|
||||||
|
@Column(name = "price", nullable = false, precision = 20, scale = 8)
|
||||||
|
val price: BigDecimal,
|
||||||
|
|
||||||
|
@Column(name = "amount", nullable = false, precision = 20, scale = 8)
|
||||||
|
val amount: BigDecimal,
|
||||||
|
|
||||||
|
@Column(name = "fee", nullable = false, precision = 20, scale = 8)
|
||||||
|
val fee: BigDecimal = BigDecimal.ZERO, // 手续费(回测不计算,默认为0)
|
||||||
|
|
||||||
|
@Column(name = "profit_loss", precision = 20, scale = 8)
|
||||||
|
val profitLoss: BigDecimal? = null, // 盈亏(仅卖出时)
|
||||||
|
|
||||||
|
@Column(name = "balance_after", nullable = false, precision = 20, scale = 8)
|
||||||
|
val balanceAfter: BigDecimal, // 交易后余额
|
||||||
|
|
||||||
|
@Column(name = "leader_trade_id", length = 100)
|
||||||
|
val leaderTradeId: String? = null, // Leader 原始交易ID
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
val createdAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
@@ -38,6 +38,9 @@ data class CopyOrderTracking(
|
|||||||
@Column(name = "leader_buy_trade_id", nullable = false, length = 100)
|
@Column(name = "leader_buy_trade_id", nullable = false, length = 100)
|
||||||
val leaderBuyTradeId: String, // Leader 买入交易ID
|
val leaderBuyTradeId: String, // Leader 买入交易ID
|
||||||
|
|
||||||
|
@Column(name = "leader_buy_quantity", nullable = true, precision = 20, scale = 8)
|
||||||
|
val leaderBuyQuantity: BigDecimal? = null, // Leader 买入数量(用于固定金额模式计算卖出比例)
|
||||||
|
|
||||||
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
|
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
|
||||||
val quantity: BigDecimal, // 买入数量
|
val quantity: BigDecimal, // 买入数量
|
||||||
|
|
||||||
@@ -53,6 +56,12 @@ data class CopyOrderTracking(
|
|||||||
@Column(name = "status", nullable = false, length = 20)
|
@Column(name = "status", nullable = false, length = 20)
|
||||||
var status: String = "filled", // filled, fully_matched, partially_matched
|
var status: String = "filled", // filled, fully_matched, partially_matched
|
||||||
|
|
||||||
|
@Column(name = "notification_sent", nullable = false)
|
||||||
|
var notificationSent: Boolean = false, // 是否已发送通知(从订单详情获取实际数据后发送)
|
||||||
|
|
||||||
|
@Column(name = "source", nullable = false, length = 20)
|
||||||
|
val source: String, // 订单来源:activity-ws(Polymarket WebSocket)、onchain-ws(OnChain WebSocket)
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,7 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
|||||||
* 跟单配置实体(独立配置,不再绑定模板)
|
* 跟单配置实体(独立配置,不再绑定模板)
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(
|
@Table(name = "copy_trading")
|
||||||
name = "copy_trading",
|
|
||||||
uniqueConstraints = [
|
|
||||||
UniqueConstraint(columnNames = ["account_id", "leader_id"])
|
|
||||||
]
|
|
||||||
)
|
|
||||||
data class CopyTrading(
|
data class CopyTrading(
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
@@ -32,7 +27,7 @@ data class CopyTrading(
|
|||||||
@Column(name = "copy_mode", nullable = false, length = 10)
|
@Column(name = "copy_mode", nullable = false, length = 10)
|
||||||
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
|
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
|
||||||
|
|
||||||
@Column(name = "copy_ratio", nullable = false, precision = 10, scale = 2)
|
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
|
||||||
val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效
|
val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效
|
||||||
|
|
||||||
@Column(name = "fixed_amount", precision = 20, scale = 8)
|
@Column(name = "fixed_amount", precision = 20, scale = 8)
|
||||||
@@ -84,6 +79,17 @@ data class CopyTrading(
|
|||||||
@Column(name = "max_price", precision = 20, scale = 8)
|
@Column(name = "max_price", precision = 20, scale = 8)
|
||||||
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
|
||||||
|
// 最大仓位配置
|
||||||
|
@Column(name = "max_position_value", precision = 20, scale = 8)
|
||||||
|
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||||
|
|
||||||
|
// 关键字过滤配置
|
||||||
|
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
|
||||||
|
val keywordFilterMode: String = "DISABLED", // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||||
|
|
||||||
|
@Column(name = "keywords", columnDefinition = "JSON")
|
||||||
|
val keywords: String? = null, // 关键字列表(JSON数组),例如:["NBA", "足球", "NBA总决赛"],当keywordFilterMode为DISABLED时为NULL
|
||||||
|
|
||||||
// 新增配置字段
|
// 新增配置字段
|
||||||
@Column(name = "config_name", length = 255)
|
@Column(name = "config_name", length = 255)
|
||||||
val configName: String? = null, // 配置名(可选)
|
val configName: String? = null, // 配置名(可选)
|
||||||
@@ -91,6 +97,12 @@ data class CopyTrading(
|
|||||||
@Column(name = "push_failed_orders", nullable = false)
|
@Column(name = "push_failed_orders", nullable = false)
|
||||||
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
||||||
|
|
||||||
|
@Column(name = "push_filtered_orders", nullable = false)
|
||||||
|
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
|
||||||
|
|
||||||
|
@Column(name = "max_market_end_date")
|
||||||
|
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ data class CopyTradingTemplate(
|
|||||||
@Column(name = "copy_mode", nullable = false, length = 10)
|
@Column(name = "copy_mode", nullable = false, length = 10)
|
||||||
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
|
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
|
||||||
|
|
||||||
@Column(name = "copy_ratio", nullable = false, precision = 10, scale = 2)
|
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
|
||||||
val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效
|
val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效
|
||||||
|
|
||||||
@Column(name = "fixed_amount", precision = 20, scale = 8)
|
@Column(name = "fixed_amount", precision = 20, scale = 8)
|
||||||
@@ -72,6 +72,9 @@ data class CopyTradingTemplate(
|
|||||||
@Column(name = "max_price", precision = 20, scale = 8)
|
@Column(name = "max_price", precision = 20, scale = 8)
|
||||||
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
|
||||||
|
@Column(name = "push_filtered_orders", nullable = false)
|
||||||
|
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.wrbug.polymarketbot.entity
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.enums.SpreadDirection
|
||||||
|
import com.wrbug.polymarketbot.enums.SpreadDirectionConverter
|
||||||
|
import com.wrbug.polymarketbot.enums.SpreadMode
|
||||||
|
import com.wrbug.polymarketbot.enums.SpreadModeConverter
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略实体
|
||||||
|
* 5/15 分钟 Up or Down 市场,在周期内时间窗口、价格进入区间时市价买入
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "crypto_tail_strategy")
|
||||||
|
data class CryptoTailStrategy(
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
val id: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "account_id", nullable = false)
|
||||||
|
val accountId: Long = 0L,
|
||||||
|
|
||||||
|
@Column(name = "name", length = 255)
|
||||||
|
val name: String? = null,
|
||||||
|
|
||||||
|
@Column(name = "market_slug_prefix", nullable = false, length = 64)
|
||||||
|
val marketSlugPrefix: String = "",
|
||||||
|
|
||||||
|
@Column(name = "interval_seconds", nullable = false)
|
||||||
|
val intervalSeconds: Int = 300,
|
||||||
|
|
||||||
|
@Column(name = "window_start_seconds", nullable = false)
|
||||||
|
val windowStartSeconds: Int = 0,
|
||||||
|
|
||||||
|
@Column(name = "window_end_seconds", nullable = false)
|
||||||
|
val windowEndSeconds: Int = 0,
|
||||||
|
|
||||||
|
@Column(name = "min_price", nullable = false, precision = 20, scale = 8)
|
||||||
|
val minPrice: BigDecimal = BigDecimal.ONE,
|
||||||
|
|
||||||
|
@Column(name = "max_price", nullable = false, precision = 20, scale = 8)
|
||||||
|
val maxPrice: BigDecimal = BigDecimal.ONE,
|
||||||
|
|
||||||
|
@Column(name = "amount_mode", nullable = false, length = 10)
|
||||||
|
val amountMode: String = "RATIO",
|
||||||
|
|
||||||
|
@Column(name = "amount_value", nullable = false, precision = 20, scale = 8)
|
||||||
|
val amountValue: BigDecimal = BigDecimal.ZERO,
|
||||||
|
|
||||||
|
/** 价差模式: NONE=不校验, FIXED=固定值, AUTO=历史计算 */
|
||||||
|
@Convert(converter = SpreadModeConverter::class)
|
||||||
|
@Column(name = "spread_mode", nullable = false, columnDefinition = "TINYINT")
|
||||||
|
val spreadMode: SpreadMode = SpreadMode.NONE,
|
||||||
|
|
||||||
|
/** 价差数值(FIXED 时必填;AUTO 时可存计算值) */
|
||||||
|
@Column(name = "spread_value", precision = 20, scale = 8)
|
||||||
|
val spreadValue: BigDecimal? = null,
|
||||||
|
|
||||||
|
/** 价差方向: MIN=最小价差(价差>=配置值触发),MAX=最大价差(价差<=配置值触发) */
|
||||||
|
@Convert(converter = SpreadDirectionConverter::class)
|
||||||
|
@Column(name = "spread_direction", nullable = false, columnDefinition = "TINYINT")
|
||||||
|
val spreadDirection: SpreadDirection = SpreadDirection.MIN,
|
||||||
|
|
||||||
|
@Column(name = "enabled", nullable = false)
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
var updatedAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.wrbug.polymarketbot.entity
|
||||||
|
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略触发记录
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "crypto_tail_strategy_trigger")
|
||||||
|
data class CryptoTailStrategyTrigger(
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
val id: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "strategy_id", nullable = false)
|
||||||
|
val strategyId: Long = 0L,
|
||||||
|
|
||||||
|
@Column(name = "period_start_unix", nullable = false)
|
||||||
|
val periodStartUnix: Long = 0L,
|
||||||
|
|
||||||
|
@Column(name = "market_title", length = 500)
|
||||||
|
val marketTitle: String? = null,
|
||||||
|
|
||||||
|
@Column(name = "outcome_index", nullable = false)
|
||||||
|
val outcomeIndex: Int = 0,
|
||||||
|
|
||||||
|
@Column(name = "trigger_price", nullable = false, precision = 20, scale = 8)
|
||||||
|
val triggerPrice: BigDecimal = BigDecimal.ZERO,
|
||||||
|
|
||||||
|
@Column(name = "amount_usdc", nullable = false, precision = 20, scale = 8)
|
||||||
|
val amountUsdc: BigDecimal = BigDecimal.ZERO,
|
||||||
|
|
||||||
|
@Column(name = "order_id", length = 128)
|
||||||
|
val orderId: String? = null,
|
||||||
|
|
||||||
|
@Column(name = "condition_id", length = 66)
|
||||||
|
val conditionId: String? = null,
|
||||||
|
|
||||||
|
@Column(name = "resolved", nullable = false)
|
||||||
|
val resolved: Boolean = false,
|
||||||
|
|
||||||
|
@Column(name = "winner_outcome_index")
|
||||||
|
val winnerOutcomeIndex: Int? = null,
|
||||||
|
|
||||||
|
@Column(name = "realized_pnl", precision = 20, scale = 8)
|
||||||
|
val realizedPnl: BigDecimal? = null,
|
||||||
|
|
||||||
|
@Column(name = "settled_at")
|
||||||
|
val settledAt: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "status", nullable = false, length = 20)
|
||||||
|
val status: String = "success",
|
||||||
|
|
||||||
|
@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(),
|
||||||
|
|
||||||
|
@Column(name = "notification_sent", nullable = false)
|
||||||
|
var notificationSent: Boolean = false
|
||||||
|
)
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package com.wrbug.polymarketbot.entity
|
|
||||||
|
|
||||||
import jakarta.persistence.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 失败交易实体
|
|
||||||
* 记录处理失败的交易信息
|
|
||||||
*/
|
|
||||||
@Entity
|
|
||||||
@Table(name = "failed_trade")
|
|
||||||
data class FailedTrade(
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
val id: Long? = null,
|
|
||||||
|
|
||||||
@Column(name = "leader_id", nullable = false)
|
|
||||||
val leaderId: Long,
|
|
||||||
|
|
||||||
@Column(name = "leader_trade_id", nullable = false, length = 100)
|
|
||||||
val leaderTradeId: String, // Leader 的交易ID
|
|
||||||
|
|
||||||
@Column(name = "trade_type", nullable = false, length = 10)
|
|
||||||
val tradeType: String, // BUY 或 SELL
|
|
||||||
|
|
||||||
@Column(name = "copy_trading_id", nullable = false)
|
|
||||||
val copyTradingId: Long,
|
|
||||||
|
|
||||||
@Column(name = "account_id", nullable = false)
|
|
||||||
val accountId: Long,
|
|
||||||
|
|
||||||
@Column(name = "market_id", nullable = false, length = 100)
|
|
||||||
val marketId: String,
|
|
||||||
|
|
||||||
@Column(name = "side", nullable = false, length = 10)
|
|
||||||
val side: String, // YES/NO
|
|
||||||
|
|
||||||
@Column(name = "price", nullable = false, length = 50)
|
|
||||||
val price: String, // 价格(字符串格式)
|
|
||||||
|
|
||||||
@Column(name = "size", nullable = false, length = 50)
|
|
||||||
val size: String, // 数量(字符串格式)
|
|
||||||
|
|
||||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
|
||||||
val errorMessage: String? = null, // 错误信息
|
|
||||||
|
|
||||||
@Column(name = "retry_count", nullable = false)
|
|
||||||
val retryCount: Int = 0, // 重试次数
|
|
||||||
|
|
||||||
@Column(name = "failed_at", nullable = false)
|
|
||||||
val failedAt: Long = System.currentTimeMillis(),
|
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
|
||||||
val createdAt: Long = System.currentTimeMillis()
|
|
||||||
)
|
|
||||||
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.wrbug.polymarketbot.entity
|
||||||
|
|
||||||
|
import jakarta.persistence.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 市场信息实体
|
||||||
|
* 用于缓存市场的基本信息(名称、slug等)
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "markets", indexes = [
|
||||||
|
Index(name = "idx_market_id", columnList = "market_id", unique = true)
|
||||||
|
])
|
||||||
|
data class Market(
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
val id: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "market_id", unique = true, nullable = false, length = 100)
|
||||||
|
val marketId: String, // 市场ID(condition ID)
|
||||||
|
|
||||||
|
@Column(name = "title", nullable = false, length = 500)
|
||||||
|
val title: String, // 市场名称(question)
|
||||||
|
|
||||||
|
@Column(name = "slug", length = 200)
|
||||||
|
val slug: String? = null, // 市场slug(用于显示)
|
||||||
|
|
||||||
|
@Column(name = "event_slug", length = 200)
|
||||||
|
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
|
||||||
|
|
||||||
|
@Column(name = "category", length = 50)
|
||||||
|
val category: String? = null, // 市场分类
|
||||||
|
|
||||||
|
@Column(name = "icon", length = 500)
|
||||||
|
val icon: String? = null, // 市场图标URL
|
||||||
|
|
||||||
|
@Column(name = "image", length = 500)
|
||||||
|
val image: String? = null, // 市场图片URL
|
||||||
|
|
||||||
|
@Column(name = "description", columnDefinition = "TEXT")
|
||||||
|
val description: String? = null, // 市场描述
|
||||||
|
|
||||||
|
@Column(name = "active", nullable = false)
|
||||||
|
val active: Boolean = true, // 是否活跃
|
||||||
|
|
||||||
|
@Column(name = "closed", nullable = false)
|
||||||
|
val closed: Boolean = false, // 是否已关闭
|
||||||
|
|
||||||
|
@Column(name = "archived", nullable = false)
|
||||||
|
val archived: Boolean = false, // 是否已归档
|
||||||
|
|
||||||
|
@Column(name = "end_date")
|
||||||
|
val endDate: Long? = null, // 市场截止时间(毫秒时间戳)
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
var updatedAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.wrbug.polymarketbot.entity
|
||||||
|
|
||||||
|
import jakarta.persistence.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息推送模板实体
|
||||||
|
* 用于存储用户自定义的消息模板
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "notification_templates")
|
||||||
|
data class NotificationTemplate(
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
val id: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "template_type", unique = true, nullable = false, length = 50)
|
||||||
|
val templateType: String, // ORDER_SUCCESS, ORDER_FAILED, ORDER_FILTERED, CRYPTO_TAIL_SUCCESS, REDEEM_SUCCESS, REDEEM_NO_RETURN
|
||||||
|
|
||||||
|
@Column(name = "template_content", nullable = false, columnDefinition = "TEXT")
|
||||||
|
var templateContent: String, // 模板内容,支持 {{variable}} 变量
|
||||||
|
|
||||||
|
@Column(name = "is_default", nullable = false)
|
||||||
|
var isDefault: Boolean = false, // 是否使用默认模板
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
var updatedAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.wrbug.polymarketbot.entity
|
||||||
|
|
||||||
|
import jakarta.persistence.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polygon RPC 节点配置实体
|
||||||
|
* 用于存储用户配置的 RPC 节点信息
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "rpc_node_config")
|
||||||
|
data class RpcNodeConfig(
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
val id: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "provider_type", nullable = false, length = 50)
|
||||||
|
val providerType: String, // 服务商类型: ALCHEMY, INFURA, QUICKNODE, CHAINSTACK, GETBLOCK, CUSTOM, PUBLIC
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, length = 100)
|
||||||
|
val name: String, // 节点名称
|
||||||
|
|
||||||
|
@Column(name = "http_url", nullable = false, length = 500)
|
||||||
|
val httpUrl: String, // HTTP RPC URL
|
||||||
|
|
||||||
|
@Column(name = "ws_url", length = 500)
|
||||||
|
val wsUrl: String? = null, // WebSocket URL (可选)
|
||||||
|
|
||||||
|
@Column(name = "api_key", length = 200)
|
||||||
|
val apiKey: String? = null, // API Key (加密存储)
|
||||||
|
|
||||||
|
@Column(name = "enabled", nullable = false)
|
||||||
|
var enabled: Boolean = true, // 是否启用
|
||||||
|
|
||||||
|
@Column(name = "priority", nullable = false)
|
||||||
|
var priority: Int = 0, // 优先级(数字越小优先级越高)
|
||||||
|
|
||||||
|
@Column(name = "last_check_time")
|
||||||
|
var lastCheckTime: Long? = null, // 最后检查时间(毫秒时间戳)
|
||||||
|
|
||||||
|
@Column(name = "last_check_status", length = 20)
|
||||||
|
var lastCheckStatus: String? = null, // 最后检查状态: HEALTHY, UNHEALTHY, UNKNOWN
|
||||||
|
|
||||||
|
@Column(name = "response_time_ms")
|
||||||
|
var responseTimeMs: Int? = null, // 最后一次响应时间(毫秒)
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
var updatedAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 节点健康状态枚举
|
||||||
|
*/
|
||||||
|
enum class NodeHealthStatus {
|
||||||
|
HEALTHY, // 健康
|
||||||
|
UNHEALTHY, // 不健康
|
||||||
|
UNKNOWN // 未知
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 节点服务商类型枚举
|
||||||
|
*/
|
||||||
|
enum class RpcProviderType {
|
||||||
|
ALCHEMY,
|
||||||
|
INFURA,
|
||||||
|
QUICKNODE,
|
||||||
|
CHAINSTACK,
|
||||||
|
GETBLOCK,
|
||||||
|
CUSTOM,
|
||||||
|
PUBLIC
|
||||||
|
}
|
||||||
@@ -41,6 +41,9 @@ data class SellMatchRecord(
|
|||||||
@Column(name = "total_realized_pnl", nullable = false, precision = 20, scale = 8)
|
@Column(name = "total_realized_pnl", nullable = false, precision = 20, scale = 8)
|
||||||
val totalRealizedPnl: BigDecimal, // 总已实现盈亏
|
val totalRealizedPnl: BigDecimal, // 总已实现盈亏
|
||||||
|
|
||||||
|
@Column(name = "price_updated", nullable = false)
|
||||||
|
var priceUpdated: Boolean = false, // 共用字段:false 表示未处理(未查询订单详情,未发送通知),true 表示已处理(已查询订单详情,已发送通知)
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
val createdAt: Long = System.currentTimeMillis()
|
val createdAt: Long = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -158,6 +158,13 @@ enum class ErrorCode(
|
|||||||
ACCOUNT_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"),
|
ACCOUNT_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"),
|
||||||
ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"),
|
ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"),
|
||||||
|
|
||||||
|
// 加密价差策略 (4710-4729)
|
||||||
|
CRYPTO_TAIL_STRATEGY_NOT_FOUND(4710, "加密价差策略不存在", "error.crypto_tail_strategy_not_found"),
|
||||||
|
CRYPTO_TAIL_STRATEGY_WINDOW_INVALID(4711, "时间区间开始不能大于结束", "error.crypto_tail_strategy_window_invalid"),
|
||||||
|
CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED(4712, "时间区间不能超过周期长度", "error.crypto_tail_strategy_window_exceed"),
|
||||||
|
CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID(4713, "周期仅支持 300 或 900 秒", "error.crypto_tail_strategy_interval_invalid"),
|
||||||
|
CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID(4714, "投入方式仅支持 RATIO 或 FIXED", "error.crypto_tail_strategy_amount_mode_invalid"),
|
||||||
|
|
||||||
// 统计相关 (4801-4899)
|
// 统计相关 (4801-4899)
|
||||||
STATISTICS_FETCH_FAILED(4801, "获取统计信息失败", "error.statistics_fetch_failed"),
|
STATISTICS_FETCH_FAILED(4801, "获取统计信息失败", "error.statistics_fetch_failed"),
|
||||||
ORDER_LIST_FETCH_FAILED(4802, "查询订单列表失败", "error.order_list_fetch_failed"),
|
ORDER_LIST_FETCH_FAILED(4802, "查询订单列表失败", "error.order_list_fetch_failed"),
|
||||||
@@ -231,7 +238,33 @@ enum class ErrorCode(
|
|||||||
SERVER_ORDER_TRACKING_PROCESS_FAILED(5901, "处理订单跟踪失败", "error.server.order_tracking_process_failed"),
|
SERVER_ORDER_TRACKING_PROCESS_FAILED(5901, "处理订单跟踪失败", "error.server.order_tracking_process_failed"),
|
||||||
SERVER_ORDER_TRACKING_BUY_FAILED(5902, "处理买入订单失败", "error.server.order_tracking_buy_failed"),
|
SERVER_ORDER_TRACKING_BUY_FAILED(5902, "处理买入订单失败", "error.server.order_tracking_buy_failed"),
|
||||||
SERVER_ORDER_TRACKING_SELL_FAILED(5903, "处理卖出订单失败", "error.server.order_tracking_sell_failed"),
|
SERVER_ORDER_TRACKING_SELL_FAILED(5903, "处理卖出订单失败", "error.server.order_tracking_sell_failed"),
|
||||||
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed");
|
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed"),
|
||||||
|
|
||||||
|
// 回测服务错误 (4601-4699)
|
||||||
|
BACKTEST_TASK_NOT_FOUND(4601, "回测任务不存在", "error.backtest.task_not_found"),
|
||||||
|
BACKTEST_LEADER_NOT_FOUND(4602, "Leader不存在", "error.backtest.leader_not_found"),
|
||||||
|
BACKTEST_DAYS_INVALID(4603, "回测天数超出限制", "error.backtest.days_invalid"),
|
||||||
|
BACKTEST_INITIAL_BALANCE_INVALID(4604, "初始金额无效", "error.backtest.initial_balance_invalid"),
|
||||||
|
BACKTEST_TASK_RUNNING(4605, "回测任务正在运行,无法删除", "error.backtest.task_running"),
|
||||||
|
BACKTEST_TASK_NOT_COMPLETED(4606, "仅支持对已完成的回测任务重新测试", "error.backtest.task_not_completed"),
|
||||||
|
SERVER_BACKTEST_CREATE_FAILED(5603, "创建回测任务失败", "error.server.backtest_create_failed"),
|
||||||
|
SERVER_BACKTEST_UPDATE_FAILED(5604, "更新回测任务失败", "error.server.backtest_update_failed"),
|
||||||
|
SERVER_BACKTEST_DELETE_FAILED(5605, "删除回测任务失败", "error.server.backtest_delete_failed"),
|
||||||
|
SERVER_BACKTEST_LIST_FETCH_FAILED(5606, "查询回测列表失败", "error.server.backtest_list_fetch_failed"),
|
||||||
|
SERVER_BACKTEST_DETAIL_FETCH_FAILED(5607, "查询回测详情失败", "error.server.backtest_detail_fetch_failed"),
|
||||||
|
SERVER_BACKTEST_TRADES_FETCH_FAILED(5608, "查询回测交易记录失败", "error.server.backtest_trades_fetch_failed"),
|
||||||
|
SERVER_BACKTEST_EXECUTE_FAILED(5609, "回测执行失败", "error.server.backtest_execute_failed"),
|
||||||
|
SERVER_BACKTEST_HISTORICAL_DATA_FETCH_FAILED(5610, "历史数据获取失败", "error.server.backtest_historical_data_fetch_failed"),
|
||||||
|
SERVER_BACKTEST_STOP_FAILED(5611, "停止回测任务失败", "error.server.backtest_stop_failed"),
|
||||||
|
SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed"),
|
||||||
|
SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_failed"),
|
||||||
|
|
||||||
|
// 加密价差策略服务 (5620-5629)
|
||||||
|
SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED(5620, "创建加密价差策略失败", "error.server.crypto_tail_strategy_create_failed"),
|
||||||
|
SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED(5621, "更新加密价差策略失败", "error.server.crypto_tail_strategy_update_failed"),
|
||||||
|
SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED(5622, "删除加密价差策略失败", "error.server.crypto_tail_strategy_delete_failed"),
|
||||||
|
SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED(5623, "查询加密价差策略列表失败", "error.server.crypto_tail_strategy_list_fetch_failed"),
|
||||||
|
SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5624, "查询触发记录失败", "error.server.crypto_tail_strategy_triggers_fetch_failed");
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.wrbug.polymarketbot.enums
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 价差方向枚举
|
||||||
|
*/
|
||||||
|
enum class SpreadDirection(val value: Int, val description: String) {
|
||||||
|
/**
|
||||||
|
* 最小价差:价差 >= 配置值时触发,买入价固定 0.99
|
||||||
|
*/
|
||||||
|
MIN(0, "最小价差"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最大价差:价差 <= 配置值时触发,买入价 = 触发价 + 0.02
|
||||||
|
*/
|
||||||
|
MAX(1, "最大价差");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* 从数值解析价差方向
|
||||||
|
*/
|
||||||
|
fun fromValue(value: Int?): SpreadDirection {
|
||||||
|
if (value == null) {
|
||||||
|
return MIN // 默认返回 MIN
|
||||||
|
}
|
||||||
|
return values().find { it.value == value }
|
||||||
|
?: throw IllegalArgumentException("未知的价差方向: $value")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地从数值解析价差方向,解析失败返回默认值
|
||||||
|
*/
|
||||||
|
fun fromValueOrDefault(value: Int?, default: SpreadDirection = MIN): SpreadDirection {
|
||||||
|
if (value == null) {
|
||||||
|
return default
|
||||||
|
}
|
||||||
|
return values().find { it.value == value } ?: default
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从字符串解析价差方向(兼容旧逻辑)
|
||||||
|
*/
|
||||||
|
fun fromString(value: String?): SpreadDirection {
|
||||||
|
if (value.isNullOrBlank()) {
|
||||||
|
return MIN
|
||||||
|
}
|
||||||
|
return values().find { it.name.equals(value, ignoreCase = true) }
|
||||||
|
?: throw IllegalArgumentException("未知的价差方向: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.wrbug.polymarketbot.enums
|
||||||
|
|
||||||
|
import jakarta.persistence.AttributeConverter
|
||||||
|
import jakarta.persistence.Converter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SpreadDirection 枚举的 JPA 转换器
|
||||||
|
* 数据库存储为 TINYINT (0 = MIN, 1 = MAX)
|
||||||
|
*/
|
||||||
|
@Converter(autoApply = false)
|
||||||
|
class SpreadDirectionConverter : AttributeConverter<SpreadDirection, Int> {
|
||||||
|
|
||||||
|
override fun convertToDatabaseColumn(attribute: SpreadDirection?): Int {
|
||||||
|
return attribute?.value ?: SpreadDirection.MIN.value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun convertToEntityAttribute(dbData: Int?): SpreadDirection {
|
||||||
|
return SpreadDirection.fromValueOrDefault(dbData)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.wrbug.polymarketbot.enums
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 价差模式枚举
|
||||||
|
*/
|
||||||
|
enum class SpreadMode(val value: Int, val description: String) {
|
||||||
|
/**
|
||||||
|
* 不校验价差
|
||||||
|
*/
|
||||||
|
NONE(0, "无"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 固定值:用户输入一个数值
|
||||||
|
*/
|
||||||
|
FIXED(1, "固定"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动:系统按历史 K 线计算建议价差
|
||||||
|
*/
|
||||||
|
AUTO(2, "自动");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* 从数值解析价差模式
|
||||||
|
*/
|
||||||
|
fun fromValue(value: Int?): SpreadMode {
|
||||||
|
if (value == null) {
|
||||||
|
return NONE // 默认返回 NONE
|
||||||
|
}
|
||||||
|
return values().find { it.value == value }
|
||||||
|
?: throw IllegalArgumentException("未知的价差模式: $value")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地从数值解析价差模式,解析失败返回默认值
|
||||||
|
*/
|
||||||
|
fun fromValueOrDefault(value: Int?, default: SpreadMode = NONE): SpreadMode {
|
||||||
|
if (value == null) {
|
||||||
|
return default
|
||||||
|
}
|
||||||
|
return values().find { it.value == value } ?: default
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从字符串解析价差模式(兼容旧逻辑)
|
||||||
|
*/
|
||||||
|
fun fromString(value: String?): SpreadMode {
|
||||||
|
if (value.isNullOrBlank()) {
|
||||||
|
return NONE
|
||||||
|
}
|
||||||
|
return values().find { it.name.equals(value, ignoreCase = true) }
|
||||||
|
?: throw IllegalArgumentException("未知的价差模式: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.wrbug.polymarketbot.enums
|
||||||
|
|
||||||
|
import jakarta.persistence.AttributeConverter
|
||||||
|
import jakarta.persistence.Converter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SpreadMode 枚举的 JPA 转换器
|
||||||
|
* 数据库存储为 TINYINT (0 = NONE, 1 = FIXED, 2 = AUTO)
|
||||||
|
*/
|
||||||
|
@Converter(autoApply = false)
|
||||||
|
class SpreadModeConverter : AttributeConverter<SpreadMode, Int> {
|
||||||
|
|
||||||
|
override fun convertToDatabaseColumn(attribute: SpreadMode?): Int {
|
||||||
|
return attribute?.value ?: SpreadMode.NONE.value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun convertToEntityAttribute(dbData: Int?): SpreadMode {
|
||||||
|
return SpreadMode.fromValueOrDefault(dbData)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.wrbug.polymarketbot.enums
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钱包类型枚举
|
||||||
|
*/
|
||||||
|
enum class WalletType(val value: String, val description: String) {
|
||||||
|
/**
|
||||||
|
* Magic 钱包(邮箱/OAuth 登录)
|
||||||
|
* 使用 PROXY 代理合约,通过 Builder Relayer 执行 Gasless 交易
|
||||||
|
*/
|
||||||
|
MAGIC("magic", "Magic(邮箱/OAuth登录)"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe 钱包(MetaMask 等 Web3 钱包)
|
||||||
|
* 使用 Gnosis Safe 代理合约,支持 Builder Relayer Gasless 或手动交易
|
||||||
|
*/
|
||||||
|
SAFE("safe", "Safe(Web3钱包)");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* 从字符串值解析钱包类型(不区分大小写)
|
||||||
|
*/
|
||||||
|
fun fromString(value: String?): WalletType {
|
||||||
|
if (value.isNullOrBlank()) {
|
||||||
|
return SAFE // 默认返回 SAFE
|
||||||
|
}
|
||||||
|
return values().find { it.value.equals(value, ignoreCase = true) }
|
||||||
|
?: throw IllegalArgumentException("未知的钱包类型: $value")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地从字符串值解析钱包类型(不区分大小写),解析失败返回默认值
|
||||||
|
*/
|
||||||
|
fun fromStringOrDefault(value: String?, default: WalletType = SAFE): WalletType {
|
||||||
|
if (value.isNullOrBlank()) {
|
||||||
|
return default
|
||||||
|
}
|
||||||
|
return values().find { it.value.equals(value, ignoreCase = true) } ?: default
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查字符串是否为有效的钱包类型
|
||||||
|
*/
|
||||||
|
fun isValid(value: String?): Boolean {
|
||||||
|
if (value.isNullOrBlank()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return values().any { it.value.equals(value, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package com.wrbug.polymarketbot.event
|
||||||
|
|
||||||
|
import org.springframework.context.ApplicationEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略创建/更新/启用状态变更后发布,用于立即触发一轮执行检查。
|
||||||
|
*/
|
||||||
|
class CryptoTailStrategyChangedEvent(source: Any) : ApplicationEvent(source)
|
||||||
@@ -29,5 +29,10 @@ interface AccountRepository : JpaRepository<Account, Long> {
|
|||||||
* 检查钱包地址是否存在
|
* 检查钱包地址是否存在
|
||||||
*/
|
*/
|
||||||
fun existsByWalletAddress(walletAddress: String): Boolean
|
fun existsByWalletAddress(walletAddress: String): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查代理地址是否存在
|
||||||
|
*/
|
||||||
|
fun existsByProxyAddress(proxyAddress: String): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.wrbug.polymarketbot.repository
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.entity.BacktestTask
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Modifying
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测任务Repository
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
interface BacktestTaskRepository : JpaRepository<BacktestTask, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 Leader ID 查询回测任务
|
||||||
|
*/
|
||||||
|
fun findByLeaderId(leaderId: Long): List<BacktestTask>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据状态查询回测任务
|
||||||
|
*/
|
||||||
|
fun findByStatus(status: String): List<BacktestTask>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 Leader ID 和状态查询回测任务
|
||||||
|
*/
|
||||||
|
fun findByLeaderIdAndStatus(leaderId: Long, status: String): List<BacktestTask>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 Leader ID、收益率排序查询
|
||||||
|
*/
|
||||||
|
@Query("SELECT t FROM BacktestTask t WHERE t.leaderId = :leaderId AND t.status = :status ORDER BY t.profitRate DESC")
|
||||||
|
fun findByLeaderIdAndStatusOrderByProfitRateDesc(leaderId: Long, status: String): List<BacktestTask>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据状态和创建时间倒序查询
|
||||||
|
*/
|
||||||
|
@Query("SELECT t FROM BacktestTask t WHERE t.status = :status ORDER BY t.createdAt DESC")
|
||||||
|
fun findByStatusOrderByCreatedAtDesc(status: String): List<BacktestTask>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新回测任务状态
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE BacktestTask t SET t.status = :status, t.updatedAt = :updatedAt WHERE t.id = :id")
|
||||||
|
fun updateStatus(id: Long, status: String, updatedAt: Long = System.currentTimeMillis())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新回测任务状态和错误信息
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE BacktestTask t SET t.status = :status, t.errorMessage = :errorMessage, t.updatedAt = :updatedAt WHERE t.id = :id")
|
||||||
|
fun updateStatusAndError(id: Long, status: String, errorMessage: String?, updatedAt: Long = System.currentTimeMillis())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新回测任务进度
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE BacktestTask t SET t.progress = :progress, t.updatedAt = :updatedAt WHERE t.id = :id")
|
||||||
|
fun updateProgress(id: Long, progress: Int, updatedAt: Long = System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.wrbug.polymarketbot.repository
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.entity.BacktestTrade
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回测交易记录Repository
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
interface BacktestTradeRepository : JpaRepository<BacktestTrade, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据回测任务ID查询所有交易记录
|
||||||
|
*/
|
||||||
|
fun findByBacktestTaskIdOrderByTradeTime(backtestTaskId: Long): List<BacktestTrade>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据回测任务ID分页查询交易记录
|
||||||
|
*/
|
||||||
|
@Query("SELECT t FROM BacktestTrade t WHERE t.backtestTaskId = :backtestTaskId ORDER BY t.tradeTime")
|
||||||
|
fun findByBacktestTaskId(
|
||||||
|
backtestTaskId: Long,
|
||||||
|
pageable: org.springframework.data.domain.Pageable
|
||||||
|
): org.springframework.data.domain.Page<BacktestTrade>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据回测任务ID统计交易数量
|
||||||
|
*/
|
||||||
|
fun countByBacktestTaskId(backtestTaskId: Long): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除回测任务的所有交易记录(由级联删除处理)
|
||||||
|
*/
|
||||||
|
fun deleteByBacktestTaskId(backtestTaskId: Long)
|
||||||
|
}
|
||||||
|
|
||||||
+48
@@ -50,5 +50,53 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
|
|||||||
* 根据买入订单ID查询订单跟踪记录
|
* 根据买入订单ID查询订单跟踪记录
|
||||||
*/
|
*/
|
||||||
fun findByBuyOrderId(buyOrderId: String): List<CopyOrderTracking>
|
fun findByBuyOrderId(buyOrderId: String): List<CopyOrderTracking>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询未发送通知的买入订单(用于轮询更新)
|
||||||
|
*/
|
||||||
|
fun findByNotificationSentFalse(): List<CopyOrderTracking>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定时间之前创建的订单(用于检查30秒后未成交的订单)
|
||||||
|
*/
|
||||||
|
@Query("SELECT t FROM CopyOrderTracking t WHERE t.createdAt <= :beforeTime")
|
||||||
|
fun findByCreatedAtBefore(beforeTime: Long): List<CopyOrderTracking>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定时间之前创建且状态不为指定状态的订单
|
||||||
|
*/
|
||||||
|
fun findByCreatedAtBeforeAndStatusNot(beforeTime: Long, status: String): List<CopyOrderTracking>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定跟单配置下的活跃仓位数量
|
||||||
|
* 活跃仓位定义为 remainingQuantity > 0 的不同 (marketId, outcomeIndex) 组合
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(DISTINCT CONCAT(t.marketId, '_', COALESCE(t.outcomeIndex, -1))) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.remainingQuantity > 0")
|
||||||
|
fun countActivePositions(copyTradingId: Long): Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算指定跟单配置、市场和方向下的当前持仓总价值 (成本价计算)
|
||||||
|
* 按市场+方向(outcomeIndex)分别统计
|
||||||
|
*/
|
||||||
|
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.outcomeIndex = :outcomeIndex AND t.remainingQuantity > 0")
|
||||||
|
fun sumCurrentPositionValueByMarketAndOutcomeIndex(copyTradingId: Long, marketId: String, outcomeIndex: Int): BigDecimal?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定跟单配置下,创建时间超过指定时间点的未匹配订单(FIFO顺序)
|
||||||
|
* 用于避免刚创建的订单被误判为已卖出
|
||||||
|
*
|
||||||
|
* @param copyTradingId 跟单配置ID
|
||||||
|
* @param marketId 市场ID
|
||||||
|
* @param outcomeIndex 结果索引
|
||||||
|
* @param thresholdTime 时间阈值(毫秒时间戳),只查询创建时间小于该值的订单
|
||||||
|
* @return 未匹配订单列表(按创建时间升序排列)
|
||||||
|
*/
|
||||||
|
@Query("SELECT t FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.outcomeIndex = :outcomeIndex AND t.remainingQuantity > 0 AND t.createdAt < :thresholdTime ORDER BY t.createdAt ASC")
|
||||||
|
fun findUnmatchedBuyOrdersByOutcomeIndexOlderThan(
|
||||||
|
copyTradingId: Long,
|
||||||
|
marketId: String,
|
||||||
|
outcomeIndex: Int,
|
||||||
|
thresholdTime: Long
|
||||||
|
): List<CopyOrderTracking>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ interface CopyTradingRepository : JpaRepository<CopyTrading, Long> {
|
|||||||
fun findByLeaderId(leaderId: Long): List<CopyTrading>
|
fun findByLeaderId(leaderId: Long): List<CopyTrading>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据账户ID和Leader ID查找跟单
|
* 根据账户ID和Leader ID查找跟单列表
|
||||||
*/
|
*/
|
||||||
fun findByAccountIdAndLeaderId(
|
fun findByAccountIdAndLeaderId(
|
||||||
accountId: Long,
|
accountId: Long,
|
||||||
leaderId: Long
|
leaderId: Long
|
||||||
): CopyTrading?
|
): List<CopyTrading>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查找所有启用的跟单
|
* 查找所有启用的跟单
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package com.wrbug.polymarketbot.repository
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface CryptoTailStrategyRepository : JpaRepository<CryptoTailStrategy, Long> {
|
||||||
|
|
||||||
|
fun findAllByAccountId(accountId: Long): List<CryptoTailStrategy>
|
||||||
|
fun findAllByEnabledTrue(): List<CryptoTailStrategy>
|
||||||
|
fun findByAccountIdAndEnabled(accountId: Long, enabled: Boolean): List<CryptoTailStrategy>
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package com.wrbug.polymarketbot.repository
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||||
|
import org.springframework.data.domain.Page
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import org.springframework.data.repository.query.Param
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
|
interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategyTrigger, Long> {
|
||||||
|
|
||||||
|
fun findByStrategyIdAndPeriodStartUnix(strategyId: Long, periodStartUnix: Long): CryptoTailStrategyTrigger?
|
||||||
|
fun findAllByStrategyIdOrderByCreatedAtDesc(strategyId: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||||
|
fun findAllByStrategyIdAndStatusOrderByCreatedAtDesc(strategyId: Long, status: String, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||||
|
fun countByStrategyIdAndStatus(strategyId: Long, status: String): Long
|
||||||
|
|
||||||
|
fun findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, startInclusive: Long, endInclusive: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||||
|
fun findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, status: String, startInclusive: Long, endInclusive: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||||
|
fun countByStrategyIdAndCreatedAtBetween(strategyId: Long, startInclusive: Long, endInclusive: Long): Long
|
||||||
|
fun countByStrategyIdAndStatusAndCreatedAtBetween(strategyId: Long, status: String, startInclusive: Long, endInclusive: Long): Long
|
||||||
|
|
||||||
|
/** 轮询结算:仅处理下单成功的订单(status=success 且 orderId 非空)、且未结算的触发记录 */
|
||||||
|
fun findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc(status: String, resolved: Boolean): List<CryptoTailStrategyTrigger>
|
||||||
|
|
||||||
|
/** 根据订单 ID 查询加密价差策略触发记录 */
|
||||||
|
fun findByOrderId(orderId: String): CryptoTailStrategyTrigger?
|
||||||
|
|
||||||
|
/** 轮询发 TG:status=success、orderId 非空、未发过通知,按创建时间正序 */
|
||||||
|
fun findByStatusAndOrderIdIsNotNullAndNotificationSentFalseOrderByCreatedAtAsc(status: String): List<CryptoTailStrategyTrigger>
|
||||||
|
|
||||||
|
/** 策略已结算订单的总已实现盈亏(用于收益统计) */
|
||||||
|
@Query("SELECT COALESCE(SUM(t.realizedPnl), 0) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true")
|
||||||
|
fun sumRealizedPnlByStrategyId(@Param("strategyId") strategyId: Long): BigDecimal?
|
||||||
|
|
||||||
|
/** 策略已结算订单笔数(用于胜率分母) */
|
||||||
|
@Query("SELECT COUNT(t) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true")
|
||||||
|
fun countResolvedByStrategyId(@Param("strategyId") strategyId: Long): Long
|
||||||
|
|
||||||
|
/** 策略已结算中赢的笔数(outcome_index = winner_outcome_index) */
|
||||||
|
@Query("SELECT COUNT(t) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true AND t.outcomeIndex = t.winnerOutcomeIndex")
|
||||||
|
fun countWinsByStrategyId(@Param("strategyId") strategyId: Long): Long
|
||||||
|
|
||||||
|
/** 收益曲线:已结算记录,按结算时间(无则创建时间)在区间内升序 */
|
||||||
|
@Query(
|
||||||
|
"SELECT t FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true " +
|
||||||
|
"AND COALESCE(t.settledAt, t.createdAt) >= :start AND COALESCE(t.settledAt, t.createdAt) <= :end " +
|
||||||
|
"ORDER BY COALESCE(t.settledAt, t.createdAt) ASC"
|
||||||
|
)
|
||||||
|
fun findResolvedByStrategyIdAndTimeRangeOrderBySettledAsc(
|
||||||
|
@Param("strategyId") strategyId: Long,
|
||||||
|
@Param("start") start: Long,
|
||||||
|
@Param("end") end: Long
|
||||||
|
): List<CryptoTailStrategyTrigger>
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package com.wrbug.polymarketbot.repository
|
|
||||||
|
|
||||||
import com.wrbug.polymarketbot.entity.FailedTrade
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
|
||||||
import org.springframework.stereotype.Repository
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 失败交易Repository
|
|
||||||
*/
|
|
||||||
@Repository
|
|
||||||
interface FailedTradeRepository : JpaRepository<FailedTrade, Long> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据Leader ID和交易ID查询
|
|
||||||
*/
|
|
||||||
fun findByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): FailedTrade?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查是否存在失败的交易
|
|
||||||
*/
|
|
||||||
fun existsByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): Boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user