feat: 添加市场截止时间筛选功能
后端: - 为 copy_trading 表添加 max_market_end_date 字段(毫秒时间戳) - 为 markets 表添加 end_date 字段,迁移时清空已有数据 - 实现市场截止时间过滤逻辑(仅跟单截止时间小于设置时间的订单) - 更新 CopyTradingService 支持 maxMarketEndDate 的创建和更新 - 更新 FilterResult 添加 FAILED_MARKET_END_DATE 状态 - 更新 CopyOrderTrackingService 传递 marketEndDate 参数 前端: - 添加市场截止时间筛选 UI(支持小时和天两种单位) - 输入限制:仅支持整数输入(precision=0) - 更新 AddModal 和 EditModal 支持市场截止时间配置 - 更新过滤类型显示映射(MARKET_END_DATE) - 添加多语言支持(中文、英文、繁体中文) 数据库迁移: - V22: 合并添加 max_market_end_date 和 end_date 字段 - 迁移时清空 markets 表数据,确保所有数据都包含 end_date
This commit is contained in:
+119
-594
@@ -5,391 +5,63 @@ 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
|
||||||
### 平台支持
|
- **分类**: 仅支持 `sports` 和 `crypto`
|
||||||
- **仅支持**: 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 +70,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 +78,80 @@ 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)) {
|
||||||
|
// 业务逻辑
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 +159,23 @@ data class ApiResponse<T>(
|
|||||||
- ❌ 禁止使用 `!!` 除非有明确原因
|
- ❌ 禁止使用 `!!` 除非有明确原因
|
||||||
- ❌ 禁止忽略异常
|
- ❌ 禁止忽略异常
|
||||||
- ❌ 禁止硬编码配置值
|
- ❌ 禁止硬编码配置值
|
||||||
- ❌ 禁止提交敏感信息到Git
|
- ❌ 禁止提交敏感信息到 Git
|
||||||
- ❌ Controller 方法禁止使用 `suspend`
|
|
||||||
- ❌ 实体类ID禁止使用 `Long = 0`
|
|
||||||
- ❌ **禁止直接返回 mock 数据或硬编码的假数据**
|
|
||||||
- ❌ **禁止在 API 调用失败时返回 mock 数据作为默认值**
|
|
||||||
- ❌ **所有返回的数据必须来自真实的 API 调用或数据库查询**
|
|
||||||
|
|
||||||
### 配置
|
|
||||||
- ❌ 禁止使用 `application.yml`
|
|
||||||
- ❌ 禁止在代码中硬编码配置值
|
|
||||||
|
|
||||||
### 类型
|
### 类型
|
||||||
- ❌ 禁止使用 `Double` 进行数值计算
|
- ❌ 禁止使用 `Double` 进行数值计算
|
||||||
- ❌ 禁止使用 `LocalDateTime` 存储时间
|
- ❌ 禁止使用 `LocalDateTime` 存储时间
|
||||||
- ❌ 禁止实体类ID使用非空默认值
|
- ❌ 禁止实体类 ID 使用非空默认值
|
||||||
|
|
||||||
### 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')` 获取翻译
|
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ data class CopyTradingCreateRequest(
|
|||||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
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 maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,7 +80,8 @@ data class CopyTradingUpdateRequest(
|
|||||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
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 maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,6 +156,7 @@ data class CopyTradingDto(
|
|||||||
// 新增配置字段
|
// 新增配置字段
|
||||||
val configName: String? = null, // 配置名(可选)
|
val configName: String? = null, // 配置名(可选)
|
||||||
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
||||||
|
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val updatedAt: Long
|
val updatedAt: Long
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ 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 = "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(),
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ data class Market(
|
|||||||
@Column(name = "archived", nullable = false)
|
@Column(name = "archived", nullable = false)
|
||||||
val archived: Boolean = false, // 是否已归档
|
val archived: Boolean = false, // 是否已归档
|
||||||
|
|
||||||
|
@Column(name = "end_date")
|
||||||
|
val endDate: Long? = null, // 市场截止时间(毫秒时间戳)
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import com.wrbug.polymarketbot.util.getEventSlug
|
|||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 市场信息服务
|
* 市场信息服务
|
||||||
@@ -173,6 +175,7 @@ class MarketService(
|
|||||||
active = marketResponse.active ?: existingMarket.active,
|
active = marketResponse.active ?: existingMarket.active,
|
||||||
closed = marketResponse.closed ?: existingMarket.closed,
|
closed = marketResponse.closed ?: existingMarket.closed,
|
||||||
archived = marketResponse.archived ?: existingMarket.archived,
|
archived = marketResponse.archived ?: existingMarket.archived,
|
||||||
|
endDate = parseEndDate(marketResponse.endDate),
|
||||||
updatedAt = System.currentTimeMillis()
|
updatedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -189,6 +192,7 @@ class MarketService(
|
|||||||
active = marketResponse.active ?: true,
|
active = marketResponse.active ?: true,
|
||||||
closed = marketResponse.closed ?: false,
|
closed = marketResponse.closed ?: false,
|
||||||
archived = marketResponse.archived ?: false,
|
archived = marketResponse.archived ?: false,
|
||||||
|
endDate = parseEndDate(marketResponse.endDate),
|
||||||
createdAt = System.currentTimeMillis(),
|
createdAt = System.currentTimeMillis(),
|
||||||
updatedAt = System.currentTimeMillis()
|
updatedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
@@ -209,5 +213,22 @@ class MarketService(
|
|||||||
fun clearCache() {
|
fun clearCache() {
|
||||||
marketCache.invalidateAll()
|
marketCache.invalidateAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析市场截止时间(ISO 8601 格式)
|
||||||
|
*/
|
||||||
|
private fun parseEndDate(endDate: String?): Long? {
|
||||||
|
if (endDate.isNullOrBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
// ISO 8601 格式,例如:2025-03-15T12:00:00Z
|
||||||
|
Instant.parse(endDate).toEpochMilli()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn("解析市场截止时间失败: endDate=$endDate, error=${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
-1
@@ -35,6 +35,7 @@ class CopyTradingFilterService(
|
|||||||
* @param copyOrderAmount 跟单金额(USDC),用于仓位检查,如果为null则不进行仓位检查
|
* @param copyOrderAmount 跟单金额(USDC),用于仓位检查,如果为null则不进行仓位检查
|
||||||
* @param marketId 市场ID,用于仓位检查(按市场过滤仓位)
|
* @param marketId 市场ID,用于仓位检查(按市场过滤仓位)
|
||||||
* @param marketTitle 市场标题,用于关键字过滤
|
* @param marketTitle 市场标题,用于关键字过滤
|
||||||
|
* @param marketEndDate 市场截止时间,用于市场截止时间检查
|
||||||
* @return 过滤结果
|
* @return 过滤结果
|
||||||
*/
|
*/
|
||||||
suspend fun checkFilters(
|
suspend fun checkFilters(
|
||||||
@@ -43,7 +44,8 @@ class CopyTradingFilterService(
|
|||||||
tradePrice: BigDecimal? = null, // Leader 交易价格,用于价格区间检查
|
tradePrice: BigDecimal? = null, // Leader 交易价格,用于价格区间检查
|
||||||
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
|
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
|
||||||
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
|
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
|
||||||
marketTitle: String? = null // 市场标题,用于关键字过滤
|
marketTitle: String? = null, // 市场标题,用于关键字过滤
|
||||||
|
marketEndDate: Long? = null // 市场截止时间,用于市场截止时间检查
|
||||||
): FilterResult {
|
): FilterResult {
|
||||||
// 1. 关键字过滤检查(如果配置了关键字过滤)
|
// 1. 关键字过滤检查(如果配置了关键字过滤)
|
||||||
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
|
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
|
||||||
@@ -53,6 +55,14 @@ class CopyTradingFilterService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1.5. 市场截止时间检查(如果配置了市场截止时间限制)
|
||||||
|
if (copyTrading.maxMarketEndDate != null) {
|
||||||
|
val marketEndDateCheck = checkMarketEndDate(copyTrading, marketEndDate)
|
||||||
|
if (!marketEndDateCheck.isPassed) {
|
||||||
|
return marketEndDateCheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 价格区间检查(如果配置了价格区间)
|
// 2. 价格区间检查(如果配置了价格区间)
|
||||||
if (tradePrice != null) {
|
if (tradePrice != null) {
|
||||||
val priceRangeCheck = checkPriceRange(copyTrading, tradePrice)
|
val priceRangeCheck = checkPriceRange(copyTrading, tradePrice)
|
||||||
@@ -358,5 +368,38 @@ class CopyTradingFilterService(
|
|||||||
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
|
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查市场截止时间
|
||||||
|
* @param copyTrading 跟单配置
|
||||||
|
* @param marketEndDate 市场截止时间(毫秒时间戳)
|
||||||
|
* @return 过滤结果
|
||||||
|
*/
|
||||||
|
private fun checkMarketEndDate(
|
||||||
|
copyTrading: CopyTrading,
|
||||||
|
marketEndDate: Long?
|
||||||
|
): FilterResult {
|
||||||
|
// 如果未配置市场截止时间限制,直接通过
|
||||||
|
if (copyTrading.maxMarketEndDate == null) {
|
||||||
|
return FilterResult.passed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有市场截止时间,无法检查,为了安全起见,不通过
|
||||||
|
if (marketEndDate == null) {
|
||||||
|
return FilterResult.marketEndDateFailed("市场缺少截止时间信息,无法进行市场截止时间检查")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查:市场截止时间 - 当前时间 <= 最大限制时间
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
val remainingTime = marketEndDate - currentTime
|
||||||
|
|
||||||
|
if (remainingTime > copyTrading.maxMarketEndDate) {
|
||||||
|
return FilterResult.marketEndDateFailed(
|
||||||
|
"市场截止时间超出限制: 剩余时间=${remainingTime}ms (${remainingTime / (1000 * 60 * 60)}小时) > 最大限制=${copyTrading.maxMarketEndDate}ms (${copyTrading.maxMarketEndDate / (1000 * 60 * 60)}小时)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return FilterResult.passed()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -94,7 +94,8 @@ class CopyTradingService(
|
|||||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||||
maxPositionCount = request.maxPositionCount,
|
maxPositionCount = request.maxPositionCount,
|
||||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||||
keywords = convertKeywordsToJson(request.keywords)
|
keywords = convertKeywordsToJson(request.keywords),
|
||||||
|
maxMarketEndDate = request.maxMarketEndDate
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// 手动输入(所有字段必须提供)
|
// 手动输入(所有字段必须提供)
|
||||||
@@ -124,7 +125,8 @@ class CopyTradingService(
|
|||||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||||
maxPositionCount = request.maxPositionCount,
|
maxPositionCount = request.maxPositionCount,
|
||||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||||
keywords = convertKeywordsToJson(request.keywords)
|
keywords = convertKeywordsToJson(request.keywords),
|
||||||
|
maxMarketEndDate = request.maxMarketEndDate
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +158,8 @@ class CopyTradingService(
|
|||||||
keywordFilterMode = config.keywordFilterMode,
|
keywordFilterMode = config.keywordFilterMode,
|
||||||
keywords = config.keywords,
|
keywords = config.keywords,
|
||||||
configName = configName,
|
configName = configName,
|
||||||
pushFailedOrders = request.pushFailedOrders ?: false
|
pushFailedOrders = request.pushFailedOrders ?: false,
|
||||||
|
maxMarketEndDate = config.maxMarketEndDate
|
||||||
)
|
)
|
||||||
|
|
||||||
val saved = copyTradingRepository.save(copyTrading)
|
val saved = copyTradingRepository.save(copyTrading)
|
||||||
@@ -233,6 +236,7 @@ class CopyTradingService(
|
|||||||
},
|
},
|
||||||
configName = configName,
|
configName = configName,
|
||||||
pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders,
|
pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders,
|
||||||
|
maxMarketEndDate = request.maxMarketEndDate ?: copyTrading.maxMarketEndDate,
|
||||||
updatedAt = System.currentTimeMillis()
|
updatedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -446,6 +450,7 @@ class CopyTradingService(
|
|||||||
keywords = convertJsonToKeywords(copyTrading.keywords),
|
keywords = convertJsonToKeywords(copyTrading.keywords),
|
||||||
configName = copyTrading.configName,
|
configName = copyTrading.configName,
|
||||||
pushFailedOrders = copyTrading.pushFailedOrders,
|
pushFailedOrders = copyTrading.pushFailedOrders,
|
||||||
|
maxMarketEndDate = copyTrading.maxMarketEndDate,
|
||||||
createdAt = copyTrading.createdAt,
|
createdAt = copyTrading.createdAt,
|
||||||
updatedAt = copyTrading.updatedAt
|
updatedAt = copyTrading.updatedAt
|
||||||
)
|
)
|
||||||
@@ -506,6 +511,7 @@ class CopyTradingService(
|
|||||||
val maxPositionValue: BigDecimal?,
|
val maxPositionValue: BigDecimal?,
|
||||||
val maxPositionCount: Int?,
|
val maxPositionCount: Int?,
|
||||||
val keywordFilterMode: String,
|
val keywordFilterMode: String,
|
||||||
val keywords: String? // JSON 字符串
|
val keywords: String?, // JSON 字符串
|
||||||
|
val maxMarketEndDate: Long? // 市场截止时间限制(毫秒时间戳)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -23,7 +23,9 @@ enum class FilterStatus {
|
|||||||
/** 失败:超过最大仓位数量 */
|
/** 失败:超过最大仓位数量 */
|
||||||
FAILED_MAX_POSITION_COUNT,
|
FAILED_MAX_POSITION_COUNT,
|
||||||
/** 失败:关键字过滤 */
|
/** 失败:关键字过滤 */
|
||||||
FAILED_KEYWORD_FILTER
|
FAILED_KEYWORD_FILTER,
|
||||||
|
/** 失败:市场截止时间超出限制 */
|
||||||
|
FAILED_MARKET_END_DATE
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,6 +99,12 @@ data class FilterResult(
|
|||||||
status = FilterStatus.FAILED_KEYWORD_FILTER,
|
status = FilterStatus.FAILED_KEYWORD_FILTER,
|
||||||
reason = reason
|
reason = reason
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 市场截止时间超出限制 */
|
||||||
|
fun marketEndDateFailed(reason: String) = FilterResult(
|
||||||
|
status = FilterStatus.FAILED_MARKET_END_DATE,
|
||||||
|
reason = reason
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -266,14 +266,18 @@ open class CopyOrderTrackingService(
|
|||||||
// 计算跟单金额(USDC)= 买入数量 × 价格
|
// 计算跟单金额(USDC)= 买入数量 × 价格
|
||||||
val copyOrderAmount = buyQuantity.multi(tradePrice)
|
val copyOrderAmount = buyQuantity.multi(tradePrice)
|
||||||
|
|
||||||
// 如果启用了关键字过滤,需要先获取市场标题
|
// 如果启用了关键字过滤或市场截止时间过滤,需要先获取市场信息
|
||||||
var marketTitle: String? = null
|
var marketTitle: String? = null
|
||||||
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
|
var marketEndDate: Long? = null
|
||||||
|
val needMarketInfo = copyTrading.keywordFilterMode != "DISABLED" || copyTrading.maxMarketEndDate != null
|
||||||
|
|
||||||
|
if (needMarketInfo) {
|
||||||
try {
|
try {
|
||||||
val market = marketService.getMarket(trade.market)
|
val market = marketService.getMarket(trade.market)
|
||||||
marketTitle = market?.title
|
marketTitle = market?.title
|
||||||
|
marketEndDate = market?.endDate
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("获取市场标题失败(关键字过滤需要): ${e.message}", e)
|
logger.warn("获取市场信息失败(关键字过滤/市场截止时间检查需要): ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +285,7 @@ open class CopyOrderTrackingService(
|
|||||||
// 传入 Leader 交易价格,用于价格区间检查
|
// 传入 Leader 交易价格,用于价格区间检查
|
||||||
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
|
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
|
||||||
// 传入市场标题,用于关键字过滤
|
// 传入市场标题,用于关键字过滤
|
||||||
|
// 传入市场截止时间,用于市场截止时间检查
|
||||||
// 订单簿只请求一次,返回给后续逻辑使用
|
// 订单簿只请求一次,返回给后续逻辑使用
|
||||||
val filterResult = filterService.checkFilters(
|
val filterResult = filterService.checkFilters(
|
||||||
copyTrading,
|
copyTrading,
|
||||||
@@ -288,7 +293,8 @@ open class CopyOrderTrackingService(
|
|||||||
tradePrice = tradePrice,
|
tradePrice = tradePrice,
|
||||||
copyOrderAmount = copyOrderAmount,
|
copyOrderAmount = copyOrderAmount,
|
||||||
marketId = trade.market,
|
marketId = trade.market,
|
||||||
marketTitle = marketTitle
|
marketTitle = marketTitle,
|
||||||
|
marketEndDate = marketEndDate
|
||||||
)
|
)
|
||||||
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
||||||
if (!filterResult.isPassed) {
|
if (!filterResult.isPassed) {
|
||||||
@@ -1403,6 +1409,7 @@ open class CopyOrderTrackingService(
|
|||||||
FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE"
|
FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE"
|
||||||
FilterStatus.FAILED_MAX_POSITION_COUNT -> "MAX_POSITION_COUNT"
|
FilterStatus.FAILED_MAX_POSITION_COUNT -> "MAX_POSITION_COUNT"
|
||||||
FilterStatus.FAILED_KEYWORD_FILTER -> "KEYWORD_FILTER"
|
FilterStatus.FAILED_KEYWORD_FILTER -> "KEYWORD_FILTER"
|
||||||
|
FilterStatus.FAILED_MARKET_END_DATE -> "MARKET_END_DATE"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- V22: 添加市场截止时间筛选功能
|
||||||
|
-- 1. 为 copy_trading 表添加 max_market_end_date 字段
|
||||||
|
-- 2. 为 markets 表添加 end_date 字段并清空已有数据
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 1. 添加市场截止时间筛选字段到 copy_trading 表
|
||||||
|
-- 仅跟单截止时间小于设置时间的订单
|
||||||
|
-- 存储毫秒时间戳,NULL 表示不启用此筛选
|
||||||
|
ALTER TABLE copy_trading
|
||||||
|
ADD COLUMN max_market_end_date BIGINT NULL COMMENT '市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用';
|
||||||
|
|
||||||
|
-- 2. 为 markets 表添加 end_date 字段
|
||||||
|
-- 删除已有数据,让系统重新从 API 获取,确保所有数据的 end_date 都正确填充
|
||||||
|
DELETE FROM markets;
|
||||||
|
|
||||||
|
-- 添加市场截止时间字段
|
||||||
|
ALTER TABLE markets
|
||||||
|
ADD COLUMN end_date BIGINT NULL COMMENT '市场截止时间(毫秒时间戳),NULL表示未设置' AFTER archived;
|
||||||
|
|
||||||
@@ -768,6 +768,14 @@
|
|||||||
"keywordExists": "Keyword already exists",
|
"keywordExists": "Keyword already exists",
|
||||||
"whitelistTooltip": "💡 Whitelist mode: only copy markets whose titles contain any of the above keywords (case-insensitive)",
|
"whitelistTooltip": "💡 Whitelist mode: only copy markets whose titles contain any of the above keywords (case-insensitive)",
|
||||||
"blacklistTooltip": "💡 Blacklist mode: do not copy markets whose titles contain any of the above keywords (case-insensitive)",
|
"blacklistTooltip": "💡 Blacklist mode: do not copy markets whose titles contain any of the above keywords (case-insensitive)",
|
||||||
|
"marketEndDateFilter": "Market End Date Limit",
|
||||||
|
"maxMarketEndDate": "Max Market End Date",
|
||||||
|
"maxMarketEndDateTooltip": "Only copy orders with market end time less than the set time. For example: 24 hours means only copy markets with less than 24 hours remaining until settlement",
|
||||||
|
"maxMarketEndDatePlaceholder": "Enter time value (optional)",
|
||||||
|
"timeUnit": "Unit",
|
||||||
|
"hour": "Hours",
|
||||||
|
"day": "Days",
|
||||||
|
"maxMarketEndDateNote": "💡 Note: Leave blank to disable this filter",
|
||||||
"configName": "Configuration Name",
|
"configName": "Configuration Name",
|
||||||
"configNameRequired": "Please enter configuration name",
|
"configNameRequired": "Please enter configuration name",
|
||||||
"configNamePlaceholder": "e.g., Copy Trading Config 1",
|
"configNamePlaceholder": "e.g., Copy Trading Config 1",
|
||||||
@@ -865,6 +873,14 @@
|
|||||||
"keywordExists": "Keyword already exists",
|
"keywordExists": "Keyword already exists",
|
||||||
"whitelistTooltip": "💡 Whitelist mode: only copy markets whose titles contain any of the above keywords (case-insensitive)",
|
"whitelistTooltip": "💡 Whitelist mode: only copy markets whose titles contain any of the above keywords (case-insensitive)",
|
||||||
"blacklistTooltip": "💡 Blacklist mode: do not copy markets whose titles contain any of the above keywords (case-insensitive)",
|
"blacklistTooltip": "💡 Blacklist mode: do not copy markets whose titles contain any of the above keywords (case-insensitive)",
|
||||||
|
"marketEndDateFilter": "Market End Date Limit",
|
||||||
|
"maxMarketEndDate": "Max Market End Date",
|
||||||
|
"maxMarketEndDateTooltip": "Only copy orders with market end time less than the set time. For example: 24 hours means only copy markets with less than 24 hours remaining until settlement",
|
||||||
|
"maxMarketEndDatePlaceholder": "Enter time value (optional)",
|
||||||
|
"timeUnit": "Unit",
|
||||||
|
"hour": "Hours",
|
||||||
|
"day": "Days",
|
||||||
|
"maxMarketEndDateNote": "💡 Note: Leave blank to disable this filter",
|
||||||
"configName": "Configuration Name",
|
"configName": "Configuration Name",
|
||||||
"configNameRequired": "Please enter configuration name",
|
"configNameRequired": "Please enter configuration name",
|
||||||
"configNamePlaceholder": "e.g., Copy Trading Config 1",
|
"configNamePlaceholder": "e.g., Copy Trading Config 1",
|
||||||
@@ -909,6 +925,7 @@
|
|||||||
"priceRange": "Price Range Mismatch",
|
"priceRange": "Price Range Mismatch",
|
||||||
"maxPositionValue": "Exceeds Max Position Value",
|
"maxPositionValue": "Exceeds Max Position Value",
|
||||||
"maxPositionCount": "Exceeds Max Position Count",
|
"maxPositionCount": "Exceeds Max Position Count",
|
||||||
|
"marketEndDate": "Market End Date Exceeds Limit",
|
||||||
"unknown": "Unknown Reason"
|
"unknown": "Unknown Reason"
|
||||||
},
|
},
|
||||||
"noData": "No filtered orders"
|
"noData": "No filtered orders"
|
||||||
|
|||||||
@@ -780,6 +780,14 @@
|
|||||||
"keywordExists": "关键字已存在",
|
"keywordExists": "关键字已存在",
|
||||||
"whitelistTooltip": "💡 白名单模式:只跟单包含上述任意关键字的市场标题(不区分大小写)",
|
"whitelistTooltip": "💡 白名单模式:只跟单包含上述任意关键字的市场标题(不区分大小写)",
|
||||||
"blacklistTooltip": "💡 黑名单模式:不跟单包含上述任意关键字的市场标题(不区分大小写)",
|
"blacklistTooltip": "💡 黑名单模式:不跟单包含上述任意关键字的市场标题(不区分大小写)",
|
||||||
|
"marketEndDateFilter": "市场截止时间限制",
|
||||||
|
"maxMarketEndDate": "最大市场截止时间",
|
||||||
|
"maxMarketEndDateTooltip": "仅跟单截止时间小于设定时间的订单。例如:24 小时表示只跟单距离结算还剩24小时以内的市场",
|
||||||
|
"maxMarketEndDatePlaceholder": "输入时间值(可选)",
|
||||||
|
"timeUnit": "单位",
|
||||||
|
"hour": "小时",
|
||||||
|
"day": "天",
|
||||||
|
"maxMarketEndDateNote": "💡 说明:不填写表示不启用此限制",
|
||||||
"supportSell": "跟单卖出",
|
"supportSell": "跟单卖出",
|
||||||
"supportSellTooltip": "是否跟单 Leader 的卖出订单",
|
"supportSellTooltip": "是否跟单 Leader 的卖出订单",
|
||||||
"create": "创建跟单配置",
|
"create": "创建跟单配置",
|
||||||
@@ -877,6 +885,14 @@
|
|||||||
"keywordExists": "关键字已存在",
|
"keywordExists": "关键字已存在",
|
||||||
"whitelistTooltip": "💡 白名单模式:只跟单包含上述任意关键字的市场标题(不区分大小写)",
|
"whitelistTooltip": "💡 白名单模式:只跟单包含上述任意关键字的市场标题(不区分大小写)",
|
||||||
"blacklistTooltip": "💡 黑名单模式:不跟单包含上述任意关键字的市场标题(不区分大小写)",
|
"blacklistTooltip": "💡 黑名单模式:不跟单包含上述任意关键字的市场标题(不区分大小写)",
|
||||||
|
"marketEndDateFilter": "市场截止时间限制",
|
||||||
|
"maxMarketEndDate": "最大市场截止时间",
|
||||||
|
"maxMarketEndDateTooltip": "仅跟单截止时间小于设定时间的订单。例如:24 小时表示只跟单距离结算还剩24小时以内的市场",
|
||||||
|
"maxMarketEndDatePlaceholder": "输入时间值(可选)",
|
||||||
|
"timeUnit": "单位",
|
||||||
|
"hour": "小时",
|
||||||
|
"day": "天",
|
||||||
|
"maxMarketEndDateNote": "💡 说明:不填写表示不启用此限制",
|
||||||
"supportSell": "跟单卖出",
|
"supportSell": "跟单卖出",
|
||||||
"supportSellTooltip": "是否跟单 Leader 的卖出订单",
|
"supportSellTooltip": "是否跟单 Leader 的卖出订单",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
@@ -909,6 +925,7 @@
|
|||||||
"priceRange": "价格区间不符",
|
"priceRange": "价格区间不符",
|
||||||
"maxPositionValue": "超过最大仓位金额",
|
"maxPositionValue": "超过最大仓位金额",
|
||||||
"maxPositionCount": "超过最大仓位数量",
|
"maxPositionCount": "超过最大仓位数量",
|
||||||
|
"marketEndDate": "市场截止时间超出限制",
|
||||||
"unknown": "未知原因"
|
"unknown": "未知原因"
|
||||||
},
|
},
|
||||||
"noData": "暂无已过滤订单"
|
"noData": "暂无已过滤订单"
|
||||||
|
|||||||
@@ -768,6 +768,14 @@
|
|||||||
"keywordExists": "關鍵字已存在",
|
"keywordExists": "關鍵字已存在",
|
||||||
"whitelistTooltip": "💡 白名單模式:只跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
|
"whitelistTooltip": "💡 白名單模式:只跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
|
||||||
"blacklistTooltip": "💡 黑名單模式:不跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
|
"blacklistTooltip": "💡 黑名單模式:不跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
|
||||||
|
"marketEndDateFilter": "市場截止時間限制",
|
||||||
|
"maxMarketEndDate": "最大市場截止時間",
|
||||||
|
"maxMarketEndDateTooltip": "僅跟單截止時間小於設定時間的訂單。例如:24 小時表示只跟單距離結算還剩24小時內的市場",
|
||||||
|
"maxMarketEndDatePlaceholder": "輸入時間值(可選)",
|
||||||
|
"timeUnit": "單位",
|
||||||
|
"hour": "小時",
|
||||||
|
"day": "天",
|
||||||
|
"maxMarketEndDateNote": "💡 說明:不填寫表示不啟用此限制",
|
||||||
"configName": "配置名",
|
"configName": "配置名",
|
||||||
"configNameRequired": "請輸入配置名",
|
"configNameRequired": "請輸入配置名",
|
||||||
"configNamePlaceholder": "例如:跟單配置1",
|
"configNamePlaceholder": "例如:跟單配置1",
|
||||||
@@ -865,6 +873,14 @@
|
|||||||
"keywordExists": "關鍵字已存在",
|
"keywordExists": "關鍵字已存在",
|
||||||
"whitelistTooltip": "💡 白名單模式:只跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
|
"whitelistTooltip": "💡 白名單模式:只跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
|
||||||
"blacklistTooltip": "💡 黑名單模式:不跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
|
"blacklistTooltip": "💡 黑名單模式:不跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
|
||||||
|
"marketEndDateFilter": "市場截止時間限制",
|
||||||
|
"maxMarketEndDate": "最大市場截止時間",
|
||||||
|
"maxMarketEndDateTooltip": "僅跟單截止時間小於設定時間的訂單。例如:24 小時表示只跟單距離結算還剩24小時內的市場",
|
||||||
|
"maxMarketEndDatePlaceholder": "輸入時間值(可選)",
|
||||||
|
"timeUnit": "單位",
|
||||||
|
"hour": "小時",
|
||||||
|
"day": "天",
|
||||||
|
"maxMarketEndDateNote": "💡 說明:不填寫表示不啟用此限制",
|
||||||
"configName": "配置名",
|
"configName": "配置名",
|
||||||
"configNameRequired": "請輸入配置名",
|
"configNameRequired": "請輸入配置名",
|
||||||
"configNamePlaceholder": "例如:跟單配置1",
|
"configNamePlaceholder": "例如:跟單配置1",
|
||||||
@@ -909,6 +925,7 @@
|
|||||||
"priceRange": "價格區間不符",
|
"priceRange": "價格區間不符",
|
||||||
"maxPositionValue": "超過最大倉位金額",
|
"maxPositionValue": "超過最大倉位金額",
|
||||||
"maxPositionCount": "超過最大倉位數量",
|
"maxPositionCount": "超過最大倉位數量",
|
||||||
|
"marketEndDate": "市場截止時間超出限制",
|
||||||
"unknown": "未知原因"
|
"unknown": "未知原因"
|
||||||
},
|
},
|
||||||
"noData": "暫無已過濾訂單"
|
"noData": "暫無已過濾訂單"
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
|
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
|
||||||
const [keywords, setKeywords] = useState<string[]>([])
|
const [keywords, setKeywords] = useState<string[]>([])
|
||||||
const keywordInputRef = useRef<InputRef>(null)
|
const keywordInputRef = useRef<InputRef>(null)
|
||||||
|
const [maxMarketEndDateValue, setMaxMarketEndDateValue] = useState<number | undefined>()
|
||||||
|
const [maxMarketEndDateUnit, setMaxMarketEndDateUnit] = useState<'HOUR' | 'DAY'>('HOUR')
|
||||||
|
|
||||||
// 导入账户modal相关状态
|
// 导入账户modal相关状态
|
||||||
const [accountImportModalVisible, setAccountImportModalVisible] = useState(false)
|
const [accountImportModalVisible, setAccountImportModalVisible] = useState(false)
|
||||||
@@ -198,7 +200,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
setKeywords(newKeywords)
|
setKeywords(newKeywords)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (values: any) => {
|
const handleSubmit = async (values: any) => {
|
||||||
// 前端校验
|
// 前端校验
|
||||||
if (values.copyMode === 'FIXED') {
|
if (values.copyMode === 'FIXED') {
|
||||||
if (!values.fixedAmount || Number(values.fixedAmount) < 1) {
|
if (!values.fixedAmount || Number(values.fixedAmount) < 1) {
|
||||||
@@ -212,6 +214,15 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算市场截止时间(毫秒)
|
||||||
|
let maxMarketEndDate: number | undefined
|
||||||
|
if (maxMarketEndDateValue !== undefined && maxMarketEndDateValue > 0) {
|
||||||
|
const multiplier = maxMarketEndDateUnit === 'HOUR'
|
||||||
|
? 60 * 60 * 1000 // 小时转毫秒
|
||||||
|
: 24 * 60 * 60 * 1000 // 天转毫秒
|
||||||
|
maxMarketEndDate = maxMarketEndDateValue * multiplier
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const request: CopyTradingCreateRequest = {
|
const request: CopyTradingCreateRequest = {
|
||||||
@@ -243,7 +254,8 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
? keywords
|
? keywords
|
||||||
: undefined,
|
: undefined,
|
||||||
configName: values.configName?.trim(),
|
configName: values.configName?.trim(),
|
||||||
pushFailedOrders: values.pushFailedOrders ?? false
|
pushFailedOrders: values.pushFailedOrders ?? false,
|
||||||
|
maxMarketEndDate
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiService.copyTrading.create(request)
|
const response = await apiService.copyTrading.create(request)
|
||||||
@@ -783,6 +795,51 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
}}
|
}}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 市场截止时间限制 */}
|
||||||
|
<Divider>{t('copyTradingAdd.marketEndDateFilter') || '市场截止时间限制'}</Divider>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('copyTradingAdd.maxMarketEndDate') || '最大市场截止时间'}
|
||||||
|
tooltip={t('copyTradingAdd.maxMarketEndDateTooltip') || '仅跟单截止时间小于设定时间的订单。例如:24 小时表示只跟单距离结算还剩24小时以内的市场'}
|
||||||
|
>
|
||||||
|
<Input.Group compact style={{ display: 'flex' }}>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={9999}
|
||||||
|
step={1}
|
||||||
|
precision={0}
|
||||||
|
value={maxMarketEndDateValue}
|
||||||
|
onChange={(value) => setMaxMarketEndDateValue(value !== null && value !== undefined ? Math.floor(value) : undefined)}
|
||||||
|
style={{ width: '60%' }}
|
||||||
|
placeholder={t('copyTradingAdd.maxMarketEndDatePlaceholder') || '输入时间值(可选)'}
|
||||||
|
parser={(value) => {
|
||||||
|
if (!value) return ''
|
||||||
|
const num = parseInt(value.replace(/\D/g, ''), 10)
|
||||||
|
return isNaN(num) ? '' : num.toString()
|
||||||
|
}}
|
||||||
|
formatter={(value) => {
|
||||||
|
if (!value && value !== 0) return ''
|
||||||
|
return Math.floor(value).toString()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={maxMarketEndDateUnit}
|
||||||
|
onChange={(value) => setMaxMarketEndDateUnit(value)}
|
||||||
|
style={{ width: '40%' }}
|
||||||
|
placeholder={t('copyTradingAdd.timeUnit') || '单位'}
|
||||||
|
>
|
||||||
|
<Option value="HOUR">{t('copyTradingAdd.hour') || '小时'}</Option>
|
||||||
|
<Option value="DAY">{t('copyTradingAdd.day') || '天'}</Option>
|
||||||
|
</Select>
|
||||||
|
</Input.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item style={{ marginBottom: 0 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#999' }}>
|
||||||
|
{t('copyTradingAdd.maxMarketEndDateNote') || '💡 说明:不填写表示不启用此限制'}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Divider>{t('copyTradingAdd.advancedSettings') || '高级设置'}</Divider>
|
<Divider>{t('copyTradingAdd.advancedSettings') || '高级设置'}</Divider>
|
||||||
|
|
||||||
{/* 跟单卖出 */}
|
{/* 跟单卖出 */}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
const [originalEnabled, setOriginalEnabled] = useState<boolean>(true)
|
const [originalEnabled, setOriginalEnabled] = useState<boolean>(true)
|
||||||
const [keywords, setKeywords] = useState<string[]>([])
|
const [keywords, setKeywords] = useState<string[]>([])
|
||||||
const keywordInputRef = useRef<InputRef>(null)
|
const keywordInputRef = useRef<InputRef>(null)
|
||||||
|
const [maxMarketEndDateValue, setMaxMarketEndDateValue] = useState<number | undefined>()
|
||||||
|
const [maxMarketEndDateUnit, setMaxMarketEndDateUnit] = useState<'HOUR' | 'DAY'>('HOUR')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && copyTradingId) {
|
if (open && copyTradingId) {
|
||||||
@@ -46,6 +48,24 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
setCopyTrading(found)
|
setCopyTrading(found)
|
||||||
setCopyMode(found.copyMode)
|
setCopyMode(found.copyMode)
|
||||||
setOriginalEnabled(found.enabled)
|
setOriginalEnabled(found.enabled)
|
||||||
|
|
||||||
|
// 解析市场截止时间(毫秒转换为小时或天)
|
||||||
|
if (found.maxMarketEndDate) {
|
||||||
|
const hours = found.maxMarketEndDate / (60 * 60 * 1000)
|
||||||
|
if (hours >= 24 && Number.isInteger(hours / 24)) {
|
||||||
|
// 大于等于24小时且是24的整数倍,使用天作为单位
|
||||||
|
setMaxMarketEndDateUnit('DAY')
|
||||||
|
setMaxMarketEndDateValue(hours / 24)
|
||||||
|
} else {
|
||||||
|
// 使用小时作为单位
|
||||||
|
setMaxMarketEndDateUnit('HOUR')
|
||||||
|
setMaxMarketEndDateValue(hours)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setMaxMarketEndDateValue(undefined)
|
||||||
|
setMaxMarketEndDateUnit('HOUR')
|
||||||
|
}
|
||||||
|
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
accountId: found.accountId,
|
accountId: found.accountId,
|
||||||
leaderId: found.leaderId,
|
leaderId: found.leaderId,
|
||||||
@@ -147,6 +167,15 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算市场截止时间(毫秒)
|
||||||
|
let maxMarketEndDate: number | undefined
|
||||||
|
if (maxMarketEndDateValue !== undefined && maxMarketEndDateValue > 0) {
|
||||||
|
const multiplier = maxMarketEndDateUnit === 'HOUR'
|
||||||
|
? 60 * 60 * 1000 // 小时转毫秒
|
||||||
|
: 24 * 60 * 60 * 1000 // 天转毫秒
|
||||||
|
maxMarketEndDate = maxMarketEndDateValue * multiplier
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const request: CopyTradingUpdateRequest = {
|
const request: CopyTradingUpdateRequest = {
|
||||||
@@ -177,7 +206,8 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
? keywords
|
? keywords
|
||||||
: undefined,
|
: undefined,
|
||||||
configName: values.configName?.trim() || undefined,
|
configName: values.configName?.trim() || undefined,
|
||||||
pushFailedOrders: values.pushFailedOrders
|
pushFailedOrders: values.pushFailedOrders,
|
||||||
|
maxMarketEndDate
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiService.copyTrading.update(request)
|
const response = await apiService.copyTrading.update(request)
|
||||||
@@ -683,6 +713,51 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
}}
|
}}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 市场截止时间限制 */}
|
||||||
|
<Divider>{t('copyTradingEdit.marketEndDateFilter') || '市场截止时间限制'}</Divider>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('copyTradingEdit.maxMarketEndDate') || '最大市场截止时间'}
|
||||||
|
tooltip={t('copyTradingEdit.maxMarketEndDateTooltip') || '仅跟单截止时间小于设定时间的订单。例如:24 小时表示只跟单距离结算还剩24小时以内的市场'}
|
||||||
|
>
|
||||||
|
<Input.Group compact style={{ display: 'flex' }}>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={9999}
|
||||||
|
step={1}
|
||||||
|
precision={0}
|
||||||
|
value={maxMarketEndDateValue}
|
||||||
|
onChange={(value) => setMaxMarketEndDateValue(value !== null && value !== undefined ? Math.floor(value) : undefined)}
|
||||||
|
style={{ width: '60%' }}
|
||||||
|
placeholder={t('copyTradingEdit.maxMarketEndDatePlaceholder') || '输入时间值(可选)'}
|
||||||
|
parser={(value) => {
|
||||||
|
if (!value) return ''
|
||||||
|
const num = parseInt(value.replace(/\D/g, ''), 10)
|
||||||
|
return isNaN(num) ? '' : num.toString()
|
||||||
|
}}
|
||||||
|
formatter={(value) => {
|
||||||
|
if (!value && value !== 0) return ''
|
||||||
|
return Math.floor(value).toString()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={maxMarketEndDateUnit}
|
||||||
|
onChange={(value) => setMaxMarketEndDateUnit(value)}
|
||||||
|
style={{ width: '40%' }}
|
||||||
|
placeholder={t('copyTradingEdit.timeUnit') || '单位'}
|
||||||
|
>
|
||||||
|
<Option value="HOUR">{t('copyTradingEdit.hour') || '小时'}</Option>
|
||||||
|
<Option value="DAY">{t('copyTradingEdit.day') || '天'}</Option>
|
||||||
|
</Select>
|
||||||
|
</Input.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item style={{ marginBottom: 0 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#999' }}>
|
||||||
|
{t('copyTradingEdit.maxMarketEndDateNote') || '💡 说明:不填写表示不启用此限制'}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Divider>{t('copyTradingEdit.advancedSettings') || '高级设置'}</Divider>
|
<Divider>{t('copyTradingEdit.advancedSettings') || '高级设置'}</Divider>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -67,7 +67,11 @@ const FilteredOrdersModal: React.FC<FilteredOrdersModalProps> = ({
|
|||||||
MARKET_STATUS: { color: 'default', text: t('filteredOrdersList.filterTypes.marketStatus') || '市场状态不可交易' },
|
MARKET_STATUS: { color: 'default', text: t('filteredOrdersList.filterTypes.marketStatus') || '市场状态不可交易' },
|
||||||
ORDERBOOK_ERROR: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookError') || '订单簿获取失败' },
|
ORDERBOOK_ERROR: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookError') || '订单簿获取失败' },
|
||||||
ORDERBOOK_EMPTY: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' },
|
ORDERBOOK_EMPTY: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' },
|
||||||
PRICE_RANGE: { color: 'purple', text: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' }
|
PRICE_RANGE: { color: 'purple', text: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' },
|
||||||
|
MAX_POSITION_VALUE: { color: 'volcano', text: t('filteredOrdersList.filterTypes.maxPositionValue') || '超过最大仓位金额' },
|
||||||
|
MAX_POSITION_COUNT: { color: 'volcano', text: t('filteredOrdersList.filterTypes.maxPositionCount') || '超过最大仓位数量' },
|
||||||
|
MARKET_END_DATE: { color: 'cyan', text: t('filteredOrdersList.filterTypes.marketEndDate') || '市场截止时间超出限制' },
|
||||||
|
KEYWORD_FILTER: { color: 'geekblue', text: t('filteredOrdersList.filterTypes.keywordFilter') || '关键字过滤' }
|
||||||
}
|
}
|
||||||
const config = typeMap[filterType] || { color: 'default', text: filterType }
|
const config = typeMap[filterType] || { color: 'default', text: filterType }
|
||||||
return <Tag color={config.color}>{config.text}</Tag>
|
return <Tag color={config.color}>{config.text}</Tag>
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ const FilteredOrdersList: React.FC = () => {
|
|||||||
'ORDERBOOK_ERROR': { color: 'default', label: t('filteredOrdersList.filterTypes.orderbookError') || '订单簿获取失败' },
|
'ORDERBOOK_ERROR': { color: 'default', label: t('filteredOrdersList.filterTypes.orderbookError') || '订单簿获取失败' },
|
||||||
'ORDERBOOK_EMPTY': { color: 'default', label: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' },
|
'ORDERBOOK_EMPTY': { color: 'default', label: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' },
|
||||||
'PRICE_RANGE': { color: 'purple', label: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' },
|
'PRICE_RANGE': { color: 'purple', label: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' },
|
||||||
|
'MAX_POSITION_VALUE': { color: 'volcano', label: t('filteredOrdersList.filterTypes.maxPositionValue') || '超过最大仓位金额' },
|
||||||
|
'MAX_POSITION_COUNT': { color: 'volcano', label: t('filteredOrdersList.filterTypes.maxPositionCount') || '超过最大仓位数量' },
|
||||||
|
'MARKET_END_DATE': { color: 'cyan', label: t('filteredOrdersList.filterTypes.marketEndDate') || '市场截止时间超出限制' },
|
||||||
|
'KEYWORD_FILTER': { color: 'geekblue', label: t('filteredOrdersList.filterTypes.keywordFilter') || '关键字过滤' },
|
||||||
'UNKNOWN': { color: 'default', label: t('filteredOrdersList.filterTypes.unknown') || '未知原因' }
|
'UNKNOWN': { color: 'default', label: t('filteredOrdersList.filterTypes.unknown') || '未知原因' }
|
||||||
}
|
}
|
||||||
const config = typeMap[type] || typeMap['UNKNOWN']
|
const config = typeMap[type] || typeMap['UNKNOWN']
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ export interface CopyTrading {
|
|||||||
// 新增配置字段
|
// 新增配置字段
|
||||||
configName?: string // 配置名(可选,但提供时必须非空)
|
configName?: string // 配置名(可选,但提供时必须非空)
|
||||||
pushFailedOrders: boolean // 推送失败订单(默认关闭)
|
pushFailedOrders: boolean // 推送失败订单(默认关闭)
|
||||||
|
maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
}
|
}
|
||||||
@@ -265,6 +266,7 @@ export interface CopyTradingCreateRequest {
|
|||||||
// 新增配置字段
|
// 新增配置字段
|
||||||
configName?: string // 配置名(可选,但提供时必须非空)
|
configName?: string // 配置名(可选,但提供时必须非空)
|
||||||
pushFailedOrders?: boolean // 推送失败订单(可选)
|
pushFailedOrders?: boolean // 推送失败订单(可选)
|
||||||
|
maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -302,6 +304,7 @@ export interface CopyTradingUpdateRequest {
|
|||||||
// 新增配置字段
|
// 新增配置字段
|
||||||
configName?: string // 配置名(可选,但提供时必须非空)
|
configName?: string // 配置名(可选,但提供时必须非空)
|
||||||
pushFailedOrders?: boolean // 推送失败订单(可选)
|
pushFailedOrders?: boolean // 推送失败订单(可选)
|
||||||
|
maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user