Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1ec0a330b | |||
| 72de65d670 | |||
| 3405a1cda3 | |||
| fc6fa8b419 | |||
| cf2c8a611c | |||
| bd323fca35 | |||
| a067a20a02 | |||
| 99c4328830 | |||
| 1df438d18d | |||
| 366d3d1b25 | |||
| 17a6a4f314 | |||
| 8600449f69 | |||
| 3d47bd50d9 | |||
| 20df471683 | |||
| 5487c7c862 | |||
| c1829fe21b | |||
| b1ad6f02d2 | |||
| 01a1836659 | |||
| b616e10ceb | |||
| d07c4c2c5e | |||
| 7485e2ce24 |
@@ -0,0 +1,39 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
- **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。
|
||||
|
||||
## 运行 check-i18n-keys
|
||||
|
||||
```bash
|
||||
cd .cursor/skills/frontend/check-i18n-keys/scripts
|
||||
npm install
|
||||
npm run check-i18n
|
||||
```
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ backend/gradle-app.setting
|
||||
backend/.gradle
|
||||
# 注意:gradle-wrapper.jar 应该被提交,不要忽略
|
||||
# backend/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
polyhub/
|
||||
# Kotlin
|
||||
*.kt.bak
|
||||
*.class
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# PolyHermes 动态更新功能 - 遗漏检查与修复报告
|
||||
|
||||
## 检查时间
|
||||
2026-01-21 03:00
|
||||
|
||||
## ✅ 已发现并修复的遗漏
|
||||
|
||||
### 1. docker-compose.prod.yml 环境变量
|
||||
- **问题**: 生产环境部署文件缺少 `ALLOW_PRERELEASE` 和 `GITHUB_REPO`。
|
||||
- **修复**: 已添加到 `docker-compose.prod.yml`。
|
||||
|
||||
### 2. 后端权限验证端点
|
||||
- **问题**: `/api/auth/verify` 端点缺失,导致 Python 更新服务无法验证管理员权限。
|
||||
- **修复**: 已在 `AuthController` 中添加 `/verify` 接口,仅允许 ADMIN 角色访问。
|
||||
|
||||
### 3. README.md 文档
|
||||
- **问题**: 未提及新功能。
|
||||
- **修复**: 已在 README 中添加"动态更新"功能说明及文档链接。
|
||||
|
||||
### 4. Docker Python 依赖优化
|
||||
- **问题**: 使用 `pip install` 可能导致依赖冲突或安装缓慢。
|
||||
- **修复**: 替换为 `apt-get install python3-flask python3-requests`,使用系统包更稳定、快速,且减小镜像体积。
|
||||
|
||||
---
|
||||
|
||||
## 🏁 最终状态
|
||||
|
||||
所有已知的遗漏都已检查并修复。系统现已准备好进行集成测试。
|
||||
|
||||
### 建议测试步骤
|
||||
|
||||
1. **本地构建测试**: `./deploy.sh` 验证 Dockerfile 更改(系统包安装)。
|
||||
2. **后端测试**: 验证 `/api/auth/verify` 接口(需登录并在 Header 带上 Token)。
|
||||
3. **流程测试**: 按计划进行 Pre-release 测试。
|
||||
|
||||
---
|
||||
**状态**: ✅ **全功能就绪,已加固**
|
||||
@@ -1,301 +0,0 @@
|
||||
# PolyHermes 动态更新功能实施完成总结
|
||||
|
||||
## ✅ 全部完成!
|
||||
|
||||
**实施时间**: 2026-01-21
|
||||
**总文件修改**: 12个
|
||||
**前端新增**: 1个组件
|
||||
**后端新增**: 1个服务
|
||||
**总代码行数**: 约2000行
|
||||
|
||||
---
|
||||
|
||||
## 📂 文件清单
|
||||
|
||||
### 后端实施(已完成)
|
||||
1. ✅ `Dockerfile` - 混合编译方案
|
||||
2. ✅ `docker/update-service.py` - P Python Flask 更新服务(573行)
|
||||
3. ✅ `docker/start.sh` - 启动3个进程
|
||||
4. ✅ `docker/nginx.conf` - Nginx 代理配置
|
||||
5. ✅ `docker-compose.yml` - 环境变量
|
||||
6. ✅ `docker-compose.test.yml` - 测试环境
|
||||
7. ✅ `.github/workflows/docker-build.yml` - CI/CD
|
||||
|
||||
### 前端实施(已完成)
|
||||
8. ✅ `frontend/src/pages/SystemUpdate.tsx` - 系统更新组件(334行)
|
||||
9. ✅ `frontend/src/pages/SystemSettings.tsx` - 集成到系统设置
|
||||
|
||||
### 文档
|
||||
10. ✅ `docs/zh/DYNAMIC_UPDATE.md` - 完整技术文档
|
||||
11. ✅ `docs/zh/IMPLEMENTATION_SUMMARY.md` - 实施总结
|
||||
12. ✅ `verify-implementation.sh` - 验证脚本
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心功能
|
||||
|
||||
### 1. 混合编译策略
|
||||
- **GitHub Actions**: 编译1次,8分钟完成
|
||||
- **本地 deploy.sh**: 完全兼容,Docker内编译
|
||||
- **构建参数**: `BUILD_IN_DOCKER` 控制编译位置
|
||||
|
||||
### 2. Pre-release 测试
|
||||
- 测试版本不推送 `latest` 标签
|
||||
- 测试版本不触发 Telegram 通知
|
||||
- 环境变量 `ALLOW_PRERELEASE=true` 启用检测
|
||||
|
||||
### 3. 更新流程
|
||||
```
|
||||
检查版本 → 下载更新包 → 备份 → 替换文件 → 重启服务 → 健康检查 → 回滚(失败时)
|
||||
```
|
||||
|
||||
### 4. 架构特点
|
||||
- **Nginx 直接代理**: `/api/update/*` → Python:9090
|
||||
- **权限验证**: Python 调用后端 `/api/auth/verify`
|
||||
- **独立服务**: 更新服务与主应用分离
|
||||
- **版本追踪**: `/app/version.json`
|
||||
|
||||
### 5. 前端UI
|
||||
- 实时进度显示
|
||||
- 版本对比
|
||||
- Release Notes 展示
|
||||
- 一键升级
|
||||
- 自动刷新
|
||||
|
||||
---
|
||||
|
||||
## 🚀 使用流程
|
||||
|
||||
### 开发测试(Pre-release)
|
||||
|
||||
```bash
|
||||
# 1. 提交代码
|
||||
git add .
|
||||
git commit -m "feat: 动态更新功能"
|
||||
git push origin dynamic_load
|
||||
|
||||
# 2. 创建测试 tag
|
||||
git tag v1.3.0-beta
|
||||
git push origin v1.3.0-beta
|
||||
|
||||
# 3. GitHub 创建 Pre-release
|
||||
# - Tag: v1.3.0-beta
|
||||
# - ✅ 勾选 "This is a pre-release"
|
||||
# - 发布
|
||||
|
||||
# 4. GitHub Actions 自动执行
|
||||
# - 编译前后端
|
||||
# - 打包更新包
|
||||
# - 上传到 Release Assets
|
||||
# - 构建 Docker 镜像(仅 v1.3.0-beta 标签)
|
||||
# -❌不推送 latest
|
||||
# - ❌ 不发送 Telegram
|
||||
|
||||
# 5. 测试环境部署
|
||||
docker pull wrbug/polyhermes:v1.3.0-beta
|
||||
docker-compose -f docker-compose.test.yml up -d
|
||||
|
||||
# 6. 测试更新功能
|
||||
# - 访问系统设置 → 系统更新
|
||||
# - 点击"检查更新"(应该检测到 v1.3.0-beta)
|
||||
# - 点击"立即升级"
|
||||
# - 验证更新流程
|
||||
```
|
||||
|
||||
### 生产发布
|
||||
|
||||
```bash
|
||||
# 测试通过后,创建正式版本
|
||||
git tag v1.3.0
|
||||
git push origin v1.3.0
|
||||
|
||||
# GitHub 创建 Release
|
||||
# - Tag: v1.3.0
|
||||
# - ❌ 不勾选 "pre-release"
|
||||
# - 发布
|
||||
|
||||
# GitHub Actions 自动执行
|
||||
# - 编译前后端
|
||||
# - 打包更新包
|
||||
# - 上传到 Release Assets
|
||||
# - 构建 Docker 镜像(v1.3.0 + latest)
|
||||
# - ✅ 推送 latest
|
||||
# - ✅ 发送 Telegram 通知
|
||||
|
||||
# 生产环境更新
|
||||
# 1. 用户访问系统设置 → 系统更新
|
||||
# 2. 点击"检查更新"
|
||||
# 3. 点击"立即升级"
|
||||
# 4. 等待30-60秒
|
||||
# 5. 页面自动刷新
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 验证清单
|
||||
|
||||
运行验证脚本:
|
||||
```bash
|
||||
./verify-implementation.sh
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
========================================
|
||||
PolyHermes 动态更新功能验证
|
||||
========================================
|
||||
|
||||
📋 检查文件...
|
||||
✅ Dockerfile
|
||||
✅ docker/update-service.py
|
||||
✅ docker/start.sh
|
||||
✅ docker/nginx.conf
|
||||
✅ docker-compose.yml
|
||||
✅ docker-compose.test.yml
|
||||
✅ .github/workflows/docker-build.yml
|
||||
✅ docs/zh/DYNAMIC_UPDATE.md
|
||||
|
||||
📋 检查关键配置...
|
||||
✅ Dockerfile 包含 BUILD_IN_DOCKER 参数
|
||||
✅ Dockerfile 安装 Python
|
||||
✅ Nginx 配置包含更新服务代理
|
||||
✅ docker-compose.yml 包含 ALLOW_PRERELEASE
|
||||
✅ GitHub Actions 包含 Pre-release 检测
|
||||
✅ GitHub Actions 包含后端编译步骤
|
||||
|
||||
📋 检查 Python 语法...
|
||||
✅ update-service.py 语法正确
|
||||
|
||||
========================================
|
||||
✅ 验证通过!所有检查项正常
|
||||
========================================
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 必须检查的端点
|
||||
|
||||
1. **健康检查端点**: `/api/system/health`
|
||||
- 用于检查后端服务是否正常
|
||||
- 如果不存在,需要修改 `Dockerfile` 和 `start.sh` 中的健康检查URL
|
||||
|
||||
2. **权限验证端点**: `/api/auth/verify`
|
||||
- 用于验证管理员权限
|
||||
- 如果不存在,有两个选择:
|
||||
- 在后端创建此端点
|
||||
- 或修改 `update-service.py` 中的权限验证逻辑
|
||||
|
||||
---
|
||||
|
||||
## 🎨 前端UI特性
|
||||
|
||||
- ✅ 当前版本显示
|
||||
- ✅ 检查更新按钮
|
||||
- ✅ 更新信息展示(版本、发布时间、Release Notes)
|
||||
- ✅ 实时进度条(0-100%)
|
||||
- ✅ 状态消息显示
|
||||
- ✅ 一键升级按钮
|
||||
- ✅ 错误处理和显示
|
||||
- ✅ 更新成功后自动刷新
|
||||
- ✅ 使用说明提示
|
||||
|
||||
---
|
||||
|
||||
## 📚 API 文档
|
||||
|
||||
### 前端调用的API
|
||||
|
||||
| 端点 | 方法 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| `/api/update/version` | GET | 获取当前版本 | 无 |
|
||||
| `/api/update/check` | GET | 检查更新 | 无 |
|
||||
| `/api/update/execute` | POST | 执行更新 | Admin |
|
||||
| `/api/update/status` | GET | 获取更新状态 | 无 |
|
||||
| `/api/update/logs` | GET | 获取更新日志 | Admin |
|
||||
|
||||
### 响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
...
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 故障排查
|
||||
|
||||
### 问题1:健康检查失败
|
||||
|
||||
**错误信息**: `后端服务启动超时`
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 检查健康检查端点
|
||||
curl http://localhost:8000/api/system/health
|
||||
|
||||
# 如果404,修改 Dockerfile 和 start.sh
|
||||
# 将 /api/system/health 改为实际存在的端点
|
||||
```
|
||||
|
||||
### 问题2:权限验证失败
|
||||
|
||||
**错误信息**: `需要管理员权限`
|
||||
|
||||
**解决方案**:
|
||||
1. 确保前端已登录且有 Admin Token
|
||||
2. 检查 `/api/auth/verify` 端点是否存在
|
||||
3. 或修改 `update-service.py` 的权限验证逻辑
|
||||
|
||||
### 问题3:更新包下载失败
|
||||
|
||||
**错误信息**: `下载更新包失败`
|
||||
|
||||
**可能原因**:
|
||||
- GitHub Release 未发布
|
||||
- 更新包文件名不符合规范
|
||||
- 网络连接问题
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 检查 Release Assets
|
||||
curl https://api.github.com/repos/WrBug/PolyHermes/releases/latest
|
||||
|
||||
# 确保文件名格式:polyhermes-{tag}-update.tar.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 性能指标
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| **GitHub Actions 构建时间** | ~8分钟 |
|
||||
| **更新包大小** | ~50MB |
|
||||
| **更新总时长** | 30-60秒 |
|
||||
| **下载时间** | 5-15秒(依网络)|
|
||||
| **备份时间** | 2-5秒 |
|
||||
| **解压时间** | 2-3秒 |
|
||||
| **重启时间** | 10-15秒 |
|
||||
| **健康检查** | 最多30秒 |
|
||||
|
||||
---
|
||||
|
||||
##✅ 实施完成状态
|
||||
|
||||
**后端**: ✅ 100% 完成
|
||||
**前端**: ✅ 100% 完成
|
||||
**文档**: ✅ 100% 完成
|
||||
**测试**: ⏳ 待验证
|
||||
|
||||
---
|
||||
|
||||
**状态**: 🎉 **实施完成,准备测试!**
|
||||
|
||||
**下一步**: 创建 Pre-release 进行测试验证
|
||||
@@ -1,31 +0,0 @@
|
||||
# PolyHermes 动态更新功能 - 遗漏检查报告
|
||||
|
||||
## 检查时间
|
||||
2026-01-21 01:46
|
||||
|
||||
## 已发现并修复的遗漏
|
||||
|
||||
### 1. ✅ docker-compose.prod.yml 缺少环境变量
|
||||
**问题**: 生产环境配置文件缺少动态更新相关的环境变量
|
||||
**修复**: 已添加 `ALLOW_PRERELEASE` 和 `GITHUB_REPO` 环境变量
|
||||
|
||||
### 2. ✅ 后端缺少权限验证端点
|
||||
**问题**: Python 更新服务需要调用 `/api/auth/verify` 验证管理员权限,但该端点不存在
|
||||
**修复**: 已在 `AuthController.kt` 中添加 `verify` 端点
|
||||
|
||||
## 继续检查项目
|
||||
|
||||
### 3. 备份文件检查
|
||||
检查是否有遗留的备份文件需要清理...
|
||||
|
||||
### 4. .gitignore 文件
|
||||
检查是否需要添加临时文件到 .gitignore...
|
||||
|
||||
### 5. 前端国际化
|
||||
检查是否需要为系统更新添加多语言支持...
|
||||
|
||||
### 6. README 文档
|
||||
检查是否需要更新 README 说明新功能...
|
||||
|
||||
### 7. 依赖检查
|
||||
检查 Python 依赖是否完整(Flask, requests)...
|
||||
@@ -165,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:独立部署(无需 clone 代码,推荐)**
|
||||
|
||||
@@ -164,6 +164,29 @@ Deploy both frontend and backend together in a single Docker container, using Ng
|
||||
|
||||
**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)**
|
||||
|
||||
**Method 1: Standalone Deployment (No code clone required, Recommended)**
|
||||
|
||||
-971
@@ -1,971 +0,0 @@
|
||||
# v1.1.10
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 📢 推送已过滤订单功能
|
||||
|
||||
- **新增推送已过滤订单功能(pushFilteredOrders)**,默认关闭
|
||||
- 支持在模板和跟单配置中配置是否推送被过滤的订单通知
|
||||
- 开启后,当订单因过滤条件(价格区间、订单深度、价差、仓位限制等)被过滤时,会发送 Telegram 通知
|
||||
- 帮助用户了解哪些订单被过滤以及过滤原因
|
||||
|
||||
- **数据库迁移**:
|
||||
- 添加 `push_filtered_orders` 字段到 `copy_trading_templates` 表
|
||||
- 添加 `push_filtered_orders` 字段到 `copy_trading` 表
|
||||
- 迁移脚本:`V24__add_push_filtered_orders_to_templates.sql`
|
||||
|
||||
- **后端实现**:
|
||||
- 在实体类(`CopyTradingTemplate`、`CopyTrading`)中添加 `pushFilteredOrders` 字段
|
||||
- 在 DTO 中添加字段支持(创建、更新、查询)
|
||||
- 在 Service 中处理字段的创建、更新和传递
|
||||
- 在发送过滤订单通知时检查 `pushFilteredOrders` 字段,只有为 `true` 时才发送
|
||||
|
||||
- **前端实现**:
|
||||
- 在模板新增、编辑、推送页面添加"推送已过滤订单"开关
|
||||
- 在跟单配置新增、编辑页面添加"推送已过滤订单"开关
|
||||
- 从模板创建跟单配置时,自动填充 `pushFilteredOrders` 字段
|
||||
- 支持多语言(中文、繁体中文、英文)
|
||||
|
||||
### 🔍 优化订单列表筛选功能
|
||||
|
||||
- **支持按市场标题搜索**:
|
||||
- 买入订单列表:添加市场标题筛选,移除方向筛选
|
||||
- 卖出订单列表:添加市场标题筛选,将方向筛选改为状态筛选
|
||||
- 已成交订单列表:添加市场标题筛选和市场列显示
|
||||
|
||||
- **改进分组体验**:
|
||||
- 记录用户的分组偏好到 localStorage,跨会话持久化
|
||||
- 所有搜索输入框添加 0.5 秒防抖优化,提升性能
|
||||
|
||||
- **后端优化**:
|
||||
- `MarketGroupedOrdersRequest` 添加 `marketId` 和 `marketTitle` 字段
|
||||
- 买入/卖出订单分组接口支持市场 ID 模糊匹配和市场标题关键字筛选
|
||||
|
||||
### ⚡ 优化跟单关系统计性能
|
||||
|
||||
- **移除未实现盈亏计算**:
|
||||
- 移除未实现盈亏和持仓价值的计算逻辑
|
||||
- 总盈亏现在仅包含已实现盈亏,计算更准确
|
||||
- 简化盈亏百分比计算,仅基于已实现盈亏
|
||||
|
||||
- **性能提升**:
|
||||
- 从 1+N 次网络请求减少到 0 次
|
||||
- 删除不再使用的方法和依赖注入
|
||||
- 统计查询速度显著提升
|
||||
|
||||
### 🐳 Docker 容器时区配置
|
||||
|
||||
- **支持通过 .env 自定义时区**:
|
||||
- 在 `docker-compose.yml` 和 `docker-compose.prod.yml` 中添加时区环境变量配置
|
||||
- 支持通过 `TZ` 环境变量自定义容器时区
|
||||
- 默认使用系统时区
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复跟单配置更新时清空可选字段无法保存的问题
|
||||
|
||||
- **问题**:修改跟单配置时,清空价格区间、最大仓位数量、截止时间等可选字段后,无法保存到数据库
|
||||
- **修复**:
|
||||
- 修复价格区间(`minPrice`/`maxPrice`)清空后无法保存的问题
|
||||
- 修复最大仓位数量(`maxPositionCount`)清空后无法保存的问题
|
||||
- 修复市场截止时间(`maxMarketEndDate`)清空后无法保存的问题
|
||||
- 修复其他可选字段(`minOrderDepth`/`maxSpread`/`maxPositionValue`)清空后无法保存的问题
|
||||
|
||||
- **实现方案**:
|
||||
- 前端:清空字段时传空字符串或 `-1` 标记,让后端识别为清空操作
|
||||
- 后端:处理空字符串和 `-1` 标记,正确设置为 `null` 以清空字段
|
||||
|
||||
### 修复新建和编辑页面的截止时间输入框交互问题
|
||||
|
||||
- **问题**:删除截止时间输入框内容后,失去焦点会自动填充 1
|
||||
- **修复**:
|
||||
- 将 `min` 从 `1` 改为 `0`,允许空值
|
||||
- 优化 `onChange` 处理,当值为 `0`、`null` 或 `undefined` 时设置为 `undefined`(清空)
|
||||
- 添加 `onBlur` 处理,确保失去焦点时如果值为空或 `0`,设置为 `undefined`
|
||||
- 修改 `parser`,空值时返回空字符串而不是 `0`
|
||||
|
||||
### 修复订单通知重复发送和时间显示问题
|
||||
|
||||
- **修复并发导致的重复通知问题**:
|
||||
- 在 `OrderStatusUpdateService` 中实现双重检查机制
|
||||
- 先保存订单标记为已发送,再重新查询数据库检查
|
||||
- 防止定时任务并发时重复发送同一订单的通知
|
||||
- 同时修复买入订单和卖出订单的通知逻辑
|
||||
|
||||
- **修复 Telegram 通知时间显示**:
|
||||
- `TelegramNotificationService.sendOrderSuccessNotification` 添加 `orderTime` 参数
|
||||
- 使用订单的 `createdAt` 时间戳作为通知显示时间
|
||||
- 而不是使用当前通知发送时间
|
||||
- 更准确反映订单的实际创建时间
|
||||
|
||||
### 修复新建跟单配置时 pushFilteredOrders 字段未生效的问题
|
||||
|
||||
- **问题**:新建跟单配置时,即使设置了 `pushFilteredOrders: true`,也没有生效
|
||||
- **修复**:
|
||||
- 修复手动输入模式下 `pushFilteredOrders` 被硬编码为 `false` 的问题
|
||||
- 修复从模板填充表单时未加载 `pushFilteredOrders` 的问题
|
||||
- 添加 `CopyTradingTemplate` 接口的 `pushFilteredOrders` 字段定义
|
||||
|
||||
## 📝 技术细节
|
||||
|
||||
### 数据库变更
|
||||
|
||||
- **迁移脚本**:`V24__add_push_filtered_orders_to_templates.sql`
|
||||
- **变更内容**:
|
||||
- `copy_trading_templates.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
|
||||
- `copy_trading.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
|
||||
- **自动执行**:升级时会自动执行迁移脚本
|
||||
|
||||
### API 变更
|
||||
|
||||
- **无新增接口**
|
||||
- **无移除接口**
|
||||
- **请求/响应变更**:
|
||||
- `CopyTradingCreateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `CopyTradingUpdateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `TemplateCreateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `TemplateUpdateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `MarketGroupedOrdersRequest` 添加 `marketId` 和 `marketTitle` 字段
|
||||
|
||||
### 前端变更
|
||||
|
||||
- **新增字段**:
|
||||
- `CopyTradingTemplate` 接口添加 `pushFilteredOrders` 字段
|
||||
- **组件更新**:
|
||||
- 模板新增、编辑、推送页面添加"推送已过滤订单"开关
|
||||
- 跟单配置新增、编辑页面添加"推送已过滤订单"开关
|
||||
- 优化截止时间输入框交互逻辑
|
||||
- **多语言支持**:
|
||||
- 添加中文、繁体中文、英文翻译
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:6 个提交
|
||||
- **文件变更**:30 个文件
|
||||
- **代码变更**:+651 行 / -555 行(净增加 96 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `CopyTrading.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
|
||||
- `CopyTradingTemplate.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
|
||||
- `CopyTradingDto.kt` - 添加 `pushFilteredOrders` 字段支持(+3 行)
|
||||
- `CopyTradingTemplateDto.kt` - 添加 `pushFilteredOrders` 字段支持(+10 行)
|
||||
- `CopyTradingService.kt` - 处理 `pushFilteredOrders` 字段和清空字段逻辑(+93 行)
|
||||
- `CopyTradingTemplateService.kt` - 处理 `pushFilteredOrders` 字段(+8 行)
|
||||
- `CopyOrderTrackingService.kt` - 检查 `pushFilteredOrders` 字段发送通知(+42 行)
|
||||
- `CopyTradingStatisticsService.kt` - 优化统计性能(-328 行)
|
||||
- `OrderStatusUpdateService.kt` - 修复重复通知问题(+142 行)
|
||||
- `TelegramNotificationService.kt` - 添加订单时间参数(+17 行)
|
||||
- `V24__add_push_filtered_orders_to_templates.sql` - 数据库迁移脚本(+13 行)
|
||||
|
||||
**前端变更**:
|
||||
- `AddModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+40 行)
|
||||
- `EditModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+59 行)
|
||||
- `TemplateAdd.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
|
||||
- `TemplateEdit.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
|
||||
- `TemplateList.tsx` - 添加 `pushFilteredOrders` 字段(+13 行)
|
||||
- `BuyOrdersTab.tsx` - 优化筛选功能(+94 行)
|
||||
- `SellOrdersTab.tsx` - 优化筛选功能(+77 行)
|
||||
- `MatchedOrdersTab.tsx` - 优化筛选功能(+26 行)
|
||||
- `types/index.ts` - 添加 `pushFilteredOrders` 字段定义(+6 行)
|
||||
- `locales/*/common.json` - 添加多语言翻译(+31 行)
|
||||
|
||||
**配置文件变更**:
|
||||
- `docker-compose.yml` - 添加时区配置(+5 行)
|
||||
- `docker-compose.prod.yml` - 添加时区配置(+5 行)
|
||||
- `Dockerfile` - 优化构建配置(+4 行)
|
||||
|
||||
## 🔄 主要提交
|
||||
|
||||
```
|
||||
9c303e0 feat: 添加推送已过滤订单功能并修复相关问题
|
||||
cb8e469 fix: 修复跟单配置更新时清空可选字段无法保存的问题
|
||||
279806d feat: 优化订单列表筛选功能,支持按市场标题搜索并改进分组体验
|
||||
90fa487 refactor: 移除未实现盈亏计算以优化跟单关系统计性能
|
||||
b58bb26 feat: 添加 Docker 容器时区配置,支持通过 .env 自定义
|
||||
6af76c4 fix: 修复订单通知重复发送和时间显示问题
|
||||
```
|
||||
|
||||
## 🎯 升级建议
|
||||
|
||||
1. **数据库迁移**:本次版本包含数据库迁移脚本,升级时会自动执行
|
||||
- 自动添加 `push_filtered_orders` 字段到模板表和跟单配置表
|
||||
- 现有数据不受影响,新字段默认值为 `false`
|
||||
|
||||
2. **配置更新**:
|
||||
- 可选:在 `.env` 文件中添加 `TZ` 环境变量自定义容器时区
|
||||
- 无需其他配置变更
|
||||
|
||||
3. **兼容性**:
|
||||
- 完全向后兼容,不影响现有功能
|
||||
- API 变更都是新增字段,不影响现有调用
|
||||
|
||||
## 📦 Docker 镜像
|
||||
|
||||
Docker 镜像会自动构建并推送到 Docker Hub:
|
||||
- `wrbug/polyhermes:v1.1.10`
|
||||
- `wrbug/polyhermes:latest`(如果这是最新版本)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.10)
|
||||
- [完整更新日志](https://github.com/WrBug/PolyHermes/compare/v1.1.9...v1.1.10)
|
||||
|
||||
---
|
||||
|
||||
**发布日期**:2026-01-12
|
||||
|
||||
---
|
||||
|
||||
# v1.1.9
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复盈亏统计计算错误问题
|
||||
|
||||
- **问题**:在仓位检查时,刚创建的订单可能因仓位更新延迟被误判为已卖出,导致盈亏统计计算错误
|
||||
- **修复**:
|
||||
- 优化自动卖出订单筛选逻辑,在 SQL 层直接过滤创建时间超过阈值的订单
|
||||
- 新增 `findUnmatchedBuyOrdersByOutcomeIndexOlderThan` Repository 方法,提高查询效率
|
||||
- 统一使用 2 分钟阈值保护刚创建的订单,避免误判
|
||||
- 两个场景都使用 SQL 过滤:
|
||||
* 场景1:仓位不存在时,延迟检测使用 SQL 过滤
|
||||
* 场景2:仓位部分存在时,FIFO 匹配使用 SQL 过滤
|
||||
|
||||
- **优化效果**:
|
||||
- SQL 层面直接过滤,减少数据传输,提高查询效率
|
||||
- 代码更简洁,逻辑更清晰
|
||||
- 刚创建的订单(< 2 分钟)不会被误判为已卖出
|
||||
- 确保盈亏统计计算的准确性
|
||||
|
||||
## 📝 技术细节
|
||||
|
||||
- **Repository 变更**:
|
||||
- 新增 `findUnmatchedBuyOrdersByOutcomeIndexOlderThan` 方法
|
||||
- 在 SQL 查询中添加 `createdAt < :thresholdTime` 条件过滤
|
||||
|
||||
- **Service 变更**:
|
||||
- `PositionCheckService` 中两个场景都使用 SQL 过滤替代内存过滤
|
||||
- 统一使用 2 分钟(120000 毫秒)作为时间阈值
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:1 个提交
|
||||
- **文件变更**:2 个文件
|
||||
- **代码变更**:+57 行 / -19 行(净增加 38 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `CopyOrderTrackingRepository.kt` - 新增 SQL 查询方法(+18 行)
|
||||
- `PositionCheckService.kt` - 优化订单筛选逻辑(+39 行 / -19 行)
|
||||
|
||||
## 🔄 主要提交
|
||||
|
||||
```
|
||||
6ad4024 fix: 优化自动卖出订单筛选逻辑,避免刚创建的订单被误判
|
||||
```
|
||||
|
||||
## 🎯 升级建议
|
||||
|
||||
1. **数据库迁移**:无需数据库迁移,可直接升级
|
||||
2. **配置更新**:无需配置变更
|
||||
3. **兼容性**:完全向后兼容,不影响现有功能
|
||||
|
||||
## 📦 Docker 镜像
|
||||
|
||||
Docker 镜像会自动构建并推送到 Docker Hub:
|
||||
- `wrbug/polyhermes:v1.1.9`
|
||||
- `wrbug/polyhermes:latest`(如果这是最新版本)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.9)
|
||||
|
||||
---
|
||||
|
||||
# v1.1.8
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### ⚡ Polymarket Activity WebSocket 双重监听方案
|
||||
|
||||
- **新增 PolymarketActivityWsService**:通过 Activity WebSocket 实时监听 Leader 交易
|
||||
- 延迟 < 100ms,适合快速跟单场景
|
||||
- 订阅全局 activity 交易流,客户端过滤 Leader 地址
|
||||
- 支持动态添加/移除 Leader 监听
|
||||
- 地址筛选忽略大小写,提高匹配准确性
|
||||
|
||||
- **双重监听机制**:
|
||||
- Activity WebSocket(低延迟,< 100ms):作为主要监听方式
|
||||
- On-Chain WebSocket(高可靠性,~2-3s):作为兜底监听方式
|
||||
- 自动去重,避免重复处理同一笔交易
|
||||
|
||||
- **协议支持**:
|
||||
- 支持 `connection_id` 字段
|
||||
- 优先使用消息中的 `outcomeIndex` 字段,fallback 到从 `outcome` 解析
|
||||
|
||||
- **参考文档**:
|
||||
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
|
||||
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
|
||||
|
||||
### 🔧 支持同一用户创建多个相同 Leader 的跟单配置
|
||||
|
||||
- **移除唯一约束**:允许同一用户创建多个跟单同一个 Leader 的配置
|
||||
- **应用场景**:支持不同参数(比例、过滤条件等)的多配置跟单
|
||||
- **数据库迁移**:`V23__remove_unique_constraint_from_copy_trading.sql`
|
||||
|
||||
### 🎯 市场截止时间筛选功能
|
||||
|
||||
- **新增市场截止时间过滤**:支持设置跟单配置的最大市场截止时间
|
||||
- **自动过滤过期市场**:系统会自动跳过超过设定截止时间的市场
|
||||
- **数据库字段**:`max_market_end_date`(时间戳,单位:秒)
|
||||
- **数据库迁移**:`V22__add_max_market_end_date_to_copy_trading.sql`
|
||||
|
||||
### 🔍 关键字过滤功能
|
||||
|
||||
- **新增关键字过滤**:支持在黑名单或白名单模式下过滤市场标题关键字
|
||||
- **过滤模式**:
|
||||
- `DISABLED`:禁用关键字过滤
|
||||
- `BLACKLIST`:黑名单模式(包含关键字的市场会被过滤)
|
||||
- `WHITELIST`:白名单模式(只允许包含关键字的市场)
|
||||
- **多关键字支持**:支持多个关键字,以 JSON 数组格式存储
|
||||
- **数据库字段**:`keyword_filter_mode` 和 `keywords`(JSON 数组)
|
||||
- **数据库迁移**:`V20__add_keyword_filter.sql`
|
||||
|
||||
### 📊 订单列表功能重构
|
||||
|
||||
- **按市场分组显示**:订单列表按市场分组,便于查看和管理
|
||||
- **市场信息展示**:
|
||||
- 显示市场标题和 slug
|
||||
- 支持跳转到 Polymarket 市场页面
|
||||
- 显示市场基本信息(标题、slug、截止时间等)
|
||||
- **订单 ID 复制功能**:一键复制订单 ID,方便查询和调试
|
||||
- **UI/UX 优化**:
|
||||
- 优化订单列表展示样式
|
||||
- 改进移动端适配
|
||||
|
||||
### 📈 市场信息管理和缓存优化
|
||||
|
||||
- **新增 MarketService**:统一管理市场信息查询和缓存
|
||||
- 使用 LRU 缓存提高查询性能
|
||||
- 支持从 Gamma API 和 CLOB API 查询市场信息
|
||||
- 自动缓存市场信息,减少 API 调用
|
||||
- **新增 Market 实体和表**:持久化存储市场信息
|
||||
- 存储市场标题、slug、eventSlug、endDate 等基本信息
|
||||
- 支持通过 marketId 快速查询市场信息
|
||||
- **数据库迁移**:
|
||||
- `V19__create_markets_table.sql`:创建市场信息表
|
||||
- `V21__add_event_slug_to_markets.sql`:添加 eventSlug 字段
|
||||
- **市场信息轮询服务**:定期更新市场信息,保持数据新鲜度
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 前端 TypeScript 类型错误修复
|
||||
|
||||
- **修复 InputNumber parser 类型错误**:`parser` 函数应返回 `number` 而不是 `string`
|
||||
- **修复文件**:
|
||||
- `src/pages/CopyTradingOrders/AddModal.tsx`
|
||||
- `src/pages/CopyTradingOrders/EditModal.tsx`
|
||||
|
||||
## 📝 文档更新
|
||||
|
||||
- **新增双重监听方案文档**:详细说明 Activity WebSocket 和 On-Chain WebSocket 的双重监听机制
|
||||
- **新增 Activity WebSocket API 文档**:详细的 API 格式和消息结构说明
|
||||
- **更新开发规范**:JSON 解析规范和 Data Class 规范
|
||||
|
||||
## 📊 统计信息
|
||||
|
||||
- **61 个文件被修改**
|
||||
- **+5125 行新增代码**
|
||||
- **-1942 行删除代码**
|
||||
- **净增加 3183 行代码**
|
||||
|
||||
## 🔄 主要提交
|
||||
|
||||
```
|
||||
d376a82 feat: 添加市场信息管理和订单ID复制功能
|
||||
2af2c0e feat: 订单列表按市场分组并支持跳转到Polymarket
|
||||
9ed5190 feat: 添加关键字过滤功能并优化市场 slug 处理
|
||||
0327eaf feat: 添加市场截止时间筛选功能
|
||||
a16b6fc feat: 支持同一用户创建多个相同 leader 的跟单配置
|
||||
19508dc feat: 实现 Polymarket Activity WebSocket 双重监听方案
|
||||
```
|
||||
|
||||
## 🎯 升级建议
|
||||
|
||||
1. **数据库迁移**:确保执行所有数据库迁移脚本(V19-V23)
|
||||
2. **配置更新**:新版本的 `application.properties` 中添加了 Activity WebSocket URL 配置
|
||||
3. **环境变量**:如果使用 Docker 部署,建议更新 `docker-compose.yml` 中的配置
|
||||
|
||||
## 📦 Docker 镜像
|
||||
|
||||
Docker 镜像会自动构建并推送到 Docker Hub:
|
||||
- `wrbug/polyhermes:v1.1.8`
|
||||
- `wrbug/polyhermes:latest`(如果这是最新版本)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.8)
|
||||
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
|
||||
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
|
||||
|
||||
---
|
||||
|
||||
# v1.1.7
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 💰 Polymarket Maker Rebates Program 费率支持
|
||||
|
||||
- **新增费率查询 API 接口** (`getFeeRate`)
|
||||
- 支持动态查询 Maker Rebates Program 费率
|
||||
- 修正 API 返回字段名:使用 `base_fee` 而非 `fee_rate_bps`(与 TypeScript clob-client 一致)
|
||||
|
||||
- **动态费率获取**
|
||||
- 在所有订单创建处动态获取费率:
|
||||
* 跟单买入订单 (`processBuyTrade`)
|
||||
* 跟单卖出订单 (`matchSellOrder`)
|
||||
* 账户卖出订单 (`sellPosition`)
|
||||
- 费率获取失败时降级到默认值 "0",确保系统可用性
|
||||
- 添加详细的日志记录,便于监控和调试
|
||||
|
||||
- **参考文档**: https://docs.polymarket.com/developers/market-makers/maker-rebates-program
|
||||
|
||||
### 🔧 Docker 部署优化
|
||||
|
||||
- **日志级别环境变量支持**
|
||||
- 在 `application.properties` 中支持通过 `LOG_LEVEL_ROOT` 和 `LOG_LEVEL_APP` 环境变量配置日志级别
|
||||
- 在 `docker-compose.yml` 和 `docker-compose.prod.yml` 中添加日志级别环境变量配置
|
||||
- 在 `deploy.sh` 的 `.env` 模板中添加日志级别配置说明
|
||||
- 支持通过环境变量动态配置日志级别,无需修改配置文件
|
||||
- 默认值:`root=INFO`, `app=DEBUG`
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复市场条件查询的 RPC 调用错误
|
||||
|
||||
- **问题**:使用错误的函数签名 `conditions(bytes32)` 导致 RPC 调用失败(execution reverted)
|
||||
- **修复**:
|
||||
- 将错误的 `conditions(bytes32)` 函数调用改为正确的 `getOutcomeSlotCount(bytes32)` 和 `payoutDenominator(bytes32)` 函数调用
|
||||
- 修复 `BlockchainService.getCondition` 方法,使用正确的 ConditionalTokens 合约函数签名
|
||||
- 改进 `MarketPriceService` 的错误处理:当链上查询出现 RPC 错误时,降级到 CLOB API 或 Gamma API 查询,而不是直接抛出异常,提高容错性
|
||||
|
||||
### 修复 RPC 错误时误创建自动卖出记录的问题
|
||||
|
||||
- **问题**:当链上查询市场条件出现 RPC 错误(execution reverted)时,系统会误判为市场已卖出,创建错误的自动卖出记录
|
||||
- **修复**:
|
||||
- 修改 `getPriceFromChainCondition` 返回 `Pair<BigDecimal?, Boolean>`,第二个值表示是否发生 RPC 错误
|
||||
- 在 `getCurrentMarketPrice` 中检测到 RPC 错误时抛出异常,`PositionCheckService` 会捕获并跳过该市场的处理
|
||||
- 避免在市场不存在或尚未创建时误判为已卖出
|
||||
|
||||
## 📝 文档更新
|
||||
|
||||
### 更新 Telegram 群链接
|
||||
|
||||
- 将所有 Telegram 群链接统一更新为 `t.me/polyhermes`
|
||||
- 更新了以下文件:
|
||||
- `frontend/src/components/Layout.tsx` - 桌面端和移动端导航链接
|
||||
- `RELEASE.md` - 相关链接
|
||||
- `README.md` 和 `README_EN.md` - 相关链接部分
|
||||
|
||||
### 添加 Docker 版本徽章
|
||||
|
||||
- 在 README 和 README_EN.md 中添加动态 Docker 版本徽章
|
||||
- 使用 shields.io 自动显示 Docker Hub 上 `wrbug/polyhermes` 镜像的最新版本
|
||||
- 版本信息自动更新,无需手动维护
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:5 个提交
|
||||
- **文件变更**:16 个文件
|
||||
- **代码变更**:+205 行 / -886 行(净减少 681 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `PolymarketClobApi.kt` - 添加费率查询接口(+25 行)
|
||||
- `AccountService.kt` - 在订单创建处添加动态费率获取(+11 行)
|
||||
- `BlockchainService.kt` - 修复市场条件查询的 RPC 调用错误(+84 行)
|
||||
- `MarketPriceService.kt` - 改进错误处理,支持降级到其他数据源(+36 行)
|
||||
- `PolymarketClobService.kt` - 添加费率查询服务(+32 行)
|
||||
- `CopyOrderTrackingService.kt` - 在跟单订单创建处添加费率获取(+34 行)
|
||||
- `PositionCheckService.kt` - 修复 RPC 错误处理逻辑(+2 行)
|
||||
- `application.properties` - 添加日志级别环境变量支持(+6 行)
|
||||
|
||||
**前端变更**:
|
||||
- `Layout.tsx` - 更新 Telegram 群链接(+4 行)
|
||||
|
||||
**配置文件变更**:
|
||||
- `docker-compose.yml` - 添加日志级别环境变量(+4 行)
|
||||
- `docker-compose.prod.yml` - 添加日志级别环境变量(+4 行)
|
||||
- `deploy.sh` - 添加日志级别配置说明(+5 行)
|
||||
|
||||
**文档变更**:
|
||||
- `README.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
|
||||
- `README_EN.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
|
||||
- `RELEASE.md` - 更新 Telegram 链接(+4 行)
|
||||
- `docs/zh/smart-money-analysis.md` - 删除文档(-836 行)
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### API 变更
|
||||
|
||||
- **新增接口**:
|
||||
- `POST /api/clob/fee-rate` - 获取 Maker Rebates Program 费率(内部使用)
|
||||
- **无移除接口**
|
||||
|
||||
### 环境变量变更
|
||||
|
||||
- **新增环境变量**:
|
||||
- `LOG_LEVEL_ROOT` - Root 日志级别(默认:INFO)
|
||||
- `LOG_LEVEL_APP` - 应用日志级别(默认:DEBUG)
|
||||
|
||||
### 合约调用修复
|
||||
|
||||
- **修复的函数调用**:
|
||||
- 从 `conditions(bytes32)` 改为 `getOutcomeSlotCount(bytes32)` 和 `payoutDenominator(bytes32)`
|
||||
- 使用正确的 ConditionalTokens 合约函数签名
|
||||
- 参考:https://polygonscan.com/address/0x4d97dcd97ec945f40cf65f87097ace5ea0476045#code
|
||||
|
||||
## 📝 升级说明
|
||||
|
||||
### 数据库升级
|
||||
|
||||
- **无需数据库迁移**:本次更新不涉及数据库结构变更
|
||||
|
||||
### 配置更新
|
||||
|
||||
- **可选配置**:新增日志级别环境变量,如不配置将使用默认值
|
||||
- `LOG_LEVEL_ROOT=INFO`(默认)
|
||||
- `LOG_LEVEL_APP=DEBUG`(默认)
|
||||
|
||||
### Docker 部署
|
||||
|
||||
- **推荐更新**:使用 Docker Hub 镜像部署的用户,建议更新到最新版本
|
||||
```bash
|
||||
docker pull wrbug/polyhermes:latest
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
- **Twitter**:@polyhermes
|
||||
- **Telegram 群组**:https://t.me/polyhermes
|
||||
|
||||
---
|
||||
|
||||
**发布日期**:2026-01-07
|
||||
|
||||
---
|
||||
|
||||
# v1.1.5
|
||||
|
||||
## 🔧 功能优化与改进
|
||||
|
||||
### 前端优化
|
||||
|
||||
#### 优化 InputNumber 输入框格式化
|
||||
- 优化数值输入框的格式化逻辑,修正正则表达式以正确处理整数显示
|
||||
- 更新所有相关 InputNumber 组件的 formatter 函数,确保显示准确性
|
||||
- 影响的组件:CopyTradingAdd、CopyTradingEdit、EditModal、TemplateAdd、TemplateEdit、TemplateList
|
||||
- 影响范围:跟单配置、模板配置中的所有数值输入框
|
||||
|
||||
#### 优化数字显示格式
|
||||
- 添加 `formatNumber` 工具函数,自动去除小数尾随零(如 100.00 → 100)
|
||||
- 统一所有数值输入框的显示格式,提升用户体验
|
||||
|
||||
### 后端优化
|
||||
|
||||
#### 优化按比例跟单金额计算逻辑
|
||||
- 优化按比例计算的订单金额处理,使用向上取整确保满足最小限制要求
|
||||
- 对订单金额进行向上取整处理(保留 2 位小数精度)
|
||||
- 自动调整订单数量以满足最小限制要求
|
||||
- 使用 `RoundingMode.CEILING` 确保金额满足最小限制
|
||||
- 影响范围:按比例跟单的订单创建逻辑
|
||||
- 技术细节:
|
||||
- 扩展 `BigDecimal.div()` 扩展函数,支持指定精度和舍入模式
|
||||
- 在 `CopyOrderTrackingService` 中优化金额计算和验证逻辑
|
||||
|
||||
#### 增强 copyRatio 精度支持
|
||||
- 将 copyRatio 字段精度从 DECIMAL(10,2) 增加到 DECIMAL(20,8)
|
||||
- 支持更精确的跟单比例设置(最小 0.01%,最大 10000%)
|
||||
- 影响的实体:CopyTrading、CopyTradingTemplate
|
||||
- 数据库迁移:新增 V18 迁移脚本,自动升级数据库字段精度
|
||||
|
||||
## 🔧 功能优化
|
||||
|
||||
### 移除刷新代理钱包接口
|
||||
- **移除接口**:
|
||||
- `POST /api/accounts/refresh-proxy` - 刷新单个账户的代理地址
|
||||
- `POST /api/accounts/refresh-all-proxies` - 刷新所有账户的代理地址
|
||||
- **原因**:代理地址应在账户导入时自动计算,无需手动刷新
|
||||
- **影响范围**:AccountController、AccountService
|
||||
- **向后兼容性**:这些接口已不再使用,移除不影响现有功能
|
||||
|
||||
### 前端跟单比例配置优化
|
||||
- **最小比例**:从 10% 降低到 0.01%,支持更灵活的跟单比例设置
|
||||
- **最大比例**:增加到 10000%,满足大比例跟单需求
|
||||
- **显示格式**:比例模式显示为百分比(如 "100%" 而不是 "1x")
|
||||
- **输入验证**:增强输入验证,确保比例在合理范围内
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:3 个提交
|
||||
- **文件变更**:15 个文件
|
||||
- **代码变更**:+575 行 / -194 行(净增加 381 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `AccountController.kt` - 移除刷新代理钱包接口(-59 行)
|
||||
- `AccountService.kt` - 移除刷新代理钱包方法(-79 行)
|
||||
- `CopyTrading.kt` - 增加 copyRatio 精度
|
||||
- `CopyTradingTemplate.kt` - 增加 copyRatio 精度
|
||||
- `CopyOrderTrackingService.kt` - 优化按比例跟单金额计算逻辑(+45 行)
|
||||
- `MathExt.kt` - 扩展 div 函数支持精度和舍入模式(+20 行)
|
||||
- `V18__increase_copy_ratio_precision.sql` - 数据库迁移脚本(+14 行)
|
||||
|
||||
**前端变更**:
|
||||
- `CopyTradingAdd.tsx` - 优化 formatter、优化比例配置(+106 行)
|
||||
- `CopyTradingEdit.tsx` - 优化 formatter、优化比例配置(+106 行)
|
||||
- `CopyTradingList.tsx` - 优化比例显示格式
|
||||
- `CopyTradingOrders/EditModal.tsx` - 优化 formatter、优化比例配置(+106 行)
|
||||
- `TemplateAdd.tsx` - 优化 formatter、优化比例配置(+65 行)
|
||||
- `TemplateEdit.tsx` - 优化 formatter、优化比例配置(+65 行)
|
||||
- `TemplateList.tsx` - 优化 formatter、优化比例配置(+65 行)
|
||||
- `utils/index.ts` - 添加 formatNumber 工具函数(+31 行)
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### 数据库变更
|
||||
- **迁移脚本**:`V18__increase_copy_ratio_precision.sql`
|
||||
- **变更内容**:
|
||||
- `copy_trading.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
|
||||
- `copy_trading_templates.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
|
||||
- **自动执行**:升级时会自动执行迁移脚本
|
||||
|
||||
### API 变更
|
||||
- **移除接口**:
|
||||
- `POST /api/accounts/refresh-proxy`
|
||||
- `POST /api/accounts/refresh-all-proxies`
|
||||
- **无新增接口**
|
||||
|
||||
### 前端变更
|
||||
- **工具函数**:新增 `formatNumber()` 函数,用于格式化数字显示
|
||||
- **组件更新**:所有数值输入框统一使用新的 formatter 函数
|
||||
- **显示优化**:跟单模式的比例显示为百分比格式
|
||||
|
||||
## 📝 升级说明
|
||||
|
||||
### 数据库升级
|
||||
本次版本包含数据库迁移脚本,升级时会自动执行:
|
||||
- 自动增加 `copy_ratio` 字段的精度
|
||||
- 现有数据不受影响,精度升级是向后兼容的
|
||||
|
||||
### 配置变更
|
||||
无需额外配置变更。
|
||||
|
||||
### 兼容性
|
||||
- **向后兼容**:所有变更都是向后兼容的
|
||||
- **API 兼容**:移除的接口不影响现有功能(这些接口已不再使用)
|
||||
- **数据兼容**:数据库字段精度升级不会影响现有数据
|
||||
|
||||
## 🎯 主要改进
|
||||
|
||||
1. **优化输入框格式化**:优化数值输入框的显示逻辑
|
||||
2. **优化跟单金额计算**:确保按比例跟单的金额满足最小限制要求
|
||||
3. **提升精度支持**:支持更精确的跟单比例设置(0.01% - 10000%)
|
||||
4. **代码清理**:移除不再使用的刷新代理钱包接口
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Tag](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.5)
|
||||
- [变更日志](https://github.com/WrBug/PolyHermes/compare/v1.1.4...v1.1.5)
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有贡献者和测试用户的反馈与支持!
|
||||
|
||||
---
|
||||
|
||||
# v1.1.2
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 🐛 修复内存泄漏问题
|
||||
- 修复 Retrofit/OkHttpClient 实例重复创建导致的内存泄漏问题
|
||||
- 为不需要认证的 API 创建共享的 OkHttpClient 实例(Gamma API、Data API、GitHub API 等)
|
||||
- 带认证的 CLOB API 按钱包地址缓存(每个账户一个客户端)
|
||||
- RPC API 按 RPC URL 缓存,Builder Relayer API 按 relayerUrl 缓存
|
||||
- 添加 `@PreDestroy` 方法清理缓存,确保资源正确释放
|
||||
- **效果**:内存占用从运行几小时后从 400MB 涨到 1GB+ 变为保持稳定,大幅减少内存占用
|
||||
|
||||
### 📊 市场价格服务优化
|
||||
- 移除降级查询逻辑,仅保留链上 RPC 查询和 CLOB 订单簿查询
|
||||
- 移除 CLOB Trades、Gamma Market Status、Gamma Market Price 查询逻辑
|
||||
- 如果所有数据源都失败,抛出明确的异常信息
|
||||
- 价格截位到 4 位小数(向下截断,不四舍五入)
|
||||
- 简化代码逻辑,提高查询效率和准确性
|
||||
|
||||
### 🔧 代码架构优化
|
||||
- 统一 Gson 使用,改为依赖注入方式
|
||||
- 在 `GsonConfig` 中统一配置 Gson Bean(lenient 模式)
|
||||
- 所有 Service 类通过构造函数注入 Gson 实例
|
||||
- 移除所有 `GsonConverterFactory.create()` 无参调用,统一使用注入的 Gson
|
||||
- 提高代码一致性和可维护性
|
||||
|
||||
### 🗑️ 功能清理
|
||||
- 移除下单失败存储数据库的功能
|
||||
- 删除 `FailedTrade` 实体类和 `FailedTradeRepository`
|
||||
- 从 `CopyOrderTrackingService` 中移除失败交易存储逻辑
|
||||
- 创建 Flyway migration V16 删除 `failed_trade` 表
|
||||
- 下单失败时仅记录日志,不再存储到数据库,简化数据模型
|
||||
|
||||
### 🚀 部署优化
|
||||
- 自动使用当前分支名作为 Docker 版本号
|
||||
- 分支名中的 `/` 自动替换为 `-`(Docker tag 不支持 `/)
|
||||
- `docker-compose.yml` 启用 build args,从环境变量读取版本号
|
||||
- 前端页面将显示当前分支名作为版本号
|
||||
- 如果没有 Git 仓库或获取失败,使用默认值 `dev`
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复 Flyway Migration 问题
|
||||
- 恢复 V1 migration 文件,避免 checksum 不匹配
|
||||
- 保持 `V1__init_database.sql` 的原有内容不变
|
||||
- `failed_trade` 表的删除通过 V16 migration 处理
|
||||
- 确保已有数据库的 migration checksum 保持一致
|
||||
|
||||
### 修复前端编译错误
|
||||
- 修复 `PositionList.tsx` 中引用不存在的 `bestBid` 属性导致的编译错误
|
||||
- 使用 `currentPrice` 替代 `bestBid`,确保前端代码可以正常编译
|
||||
|
||||
## 📚 文档更新
|
||||
|
||||
- 新增智能资金分析文档(`docs/zh/smart-money-analysis.md`)
|
||||
- 详细说明智能资金分析功能的使用方法和策略
|
||||
|
||||
## 🔧 技术改进
|
||||
|
||||
- 优化 `RetrofitFactory`,实现客户端实例缓存和复用
|
||||
- 优化 `CopyOrderTrackingService`,移除失败交易相关逻辑
|
||||
- 优化 `OrderStatusUpdateService`,增强订单状态更新功能
|
||||
- 优化 `TelegramNotificationService`,改进通知逻辑
|
||||
- 优化 `PositionCheckService`,简化代码结构
|
||||
- 优化 `PolymarketClobService`,改进 API 调用逻辑
|
||||
|
||||
## 📦 数据库变更
|
||||
|
||||
- 删除 `failed_trade` 表(Migration: V16)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.2
|
||||
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.1...v1.1.2
|
||||
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
|
||||
|
||||
## 📊 统计信息
|
||||
|
||||
- **文件变更**: 29 个文件
|
||||
- **代码变更**: +1597 行 / -678 行
|
||||
- **主要提交**: 8 个提交
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
**请务必使用官方 Docker 镜像源,避免财产损失!**
|
||||
|
||||
### ✅ 官方 Docker Hub 镜像
|
||||
|
||||
**官方镜像地址**:`wrbug/polyhermes`
|
||||
|
||||
```bash
|
||||
# ✅ 正确:使用官方镜像
|
||||
docker pull wrbug/polyhermes:v1.1.2
|
||||
|
||||
# ❌ 错误:不要使用其他来源的镜像
|
||||
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
|
||||
```
|
||||
|
||||
### 🔗 官方渠道
|
||||
|
||||
请通过以下**唯一官方渠道**获取 PolyHermes:
|
||||
|
||||
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
* **Twitter**:@polyhermes
|
||||
* **Telegram 群组**:https://t.me/polyhermes
|
||||
|
||||
---
|
||||
|
||||
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
|
||||
|
||||
---
|
||||
|
||||
# v1.1.1
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 🔗 链上 WebSocket 监听优化
|
||||
- 创建 `UnifiedOnChainWsService` 统一管理 WebSocket 连接,所有服务共享同一个连接
|
||||
- 创建 `OnChainWsUtils` 工具类,提取公共的链上 WebSocket 相关功能
|
||||
- 创建 `AccountOnChainMonitorService` 监听账户链上卖出和赎回事件
|
||||
- 优化 `OnChainWsService`,复用公共代码,减少代码重复
|
||||
- 支持通过链上 WebSocket 实时监听账户的卖出和赎回交易,自动更新订单状态
|
||||
|
||||
### 📊 市场状态查询优化
|
||||
- 优化市场结算状态查询,优先使用链上查询 `ConditionalTokens.getCondition`
|
||||
- 如果链上查询失败,自动降级到 Gamma API 查询
|
||||
- 提供更实时和准确的市场结算结果
|
||||
|
||||
### 🔕 自动订单通知优化
|
||||
- 自动生成的订单(AUTO_、AUTO_FIFO_、AUTO_WS_ 前缀)不再发送 Telegram 通知
|
||||
- 优化 `OrderStatusUpdateService`,跳过自动生成订单的通知处理
|
||||
- 减少不必要的通知,提升用户体验
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复移动端 API 健康页面缺少数据显示
|
||||
- 移动端添加 URL 地址显示
|
||||
- 移动端添加状态文本显示(正常/异常/未配置)
|
||||
- 移动端添加消息/状态信息显示
|
||||
- 移动端和桌面端显示信息保持一致
|
||||
|
||||
## 🔧 功能优化
|
||||
|
||||
### 优化 Telegram 推送消息格式
|
||||
- 添加价格和数量截位处理:
|
||||
* 价格保留最多4位小数(截断,不四舍五入)
|
||||
* 数量保留最多2位小数(截断,不四舍五入)
|
||||
- 优化账户信息显示格式:
|
||||
* 有账户名和钱包地址时显示:账户名(0x123...123)
|
||||
* 只有账户名时显示账户名
|
||||
* 只有钱包地址时显示脱敏后的地址
|
||||
* 都没有时显示未知账户
|
||||
|
||||
### 配置优化
|
||||
- 移除 `polygon.rpc.url` 配置,使用 RpcNodeService 统一管理 RPC 节点
|
||||
- 删除无用的 `position.push.polling-interval` 和 `position.push.heartbeat-timeout` 配置项
|
||||
- 修正日志配置中的包名(polyhermes -> polymarketbot)
|
||||
- 更新 `ApiHealthCheckService` 直接使用 `RpcNodeService.getHttpUrl()`
|
||||
|
||||
## 📚 文档更新
|
||||
|
||||
- 统一发布说明文件,使用 RELEASE.md 替代版本化文件(RELEASE_v1.0.1.md、RELEASE_v1.1.0.md)
|
||||
- 更新所有部署文档,移除 POLYGON_RPC_URL 相关说明
|
||||
- 更新所有 Docker Compose 配置文件,移除 POLYGON_RPC_URL 环境变量
|
||||
- 更新所有部署脚本,移除 POLYGON_RPC_URL 环境变量定义
|
||||
|
||||
## 🔧 技术改进
|
||||
|
||||
- 重构链上 WebSocket 服务,提取公共代码到 `OnChainWsUtils`
|
||||
- 创建统一的 WebSocket 连接管理服务 `UnifiedOnChainWsService`
|
||||
- 添加链上查询市场结算结果的功能(`BlockchainService.getCondition`)
|
||||
- 添加 ABI 编码/解码工具方法(`EthereumUtils.decodeConditionResult`)
|
||||
- 优化代码结构,减少代码重复,提高可维护性
|
||||
|
||||
---
|
||||
|
||||
# v1.1.0
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 🔗 链上 WebSocket 实时监听
|
||||
- 实现通过 Polygon RPC `eth_subscribe` 实时监听链上交易
|
||||
- 支持监听 USDC Transfer 和 ERC1155 Transfer 事件
|
||||
- 实现并行监控策略:链上 WebSocket 和轮询同时运行,哪个数据先返回用哪个
|
||||
- 支持通过 `eth_unsubscribe` 取消单个 Leader 的订阅,无需重新连接
|
||||
- 优化 WebSocket 连接管理:只创建一个连接,没有跟单配置时自动取消
|
||||
- 跟单配置生效/失效时及时更新 WebSocket 订阅
|
||||
- 使用 Gson 替换所有 JSON 解析,提高解析稳定性
|
||||
- 添加 Mutex 保证线程安全,防止并发处理导致的数据重复
|
||||
|
||||
### 📊 RPC 节点管理
|
||||
- 实现 RPC 节点管理功能,支持添加、编辑、删除自定义 RPC 节点
|
||||
- 支持 RPC 节点启用/禁用功能,禁用的节点会被自动忽略
|
||||
- 前端添加启用/禁用开关,支持实时切换节点状态
|
||||
- 健康检查只检查启用的节点,提高检查效率
|
||||
- 节点选择时自动过滤禁用的节点
|
||||
|
||||
### 💰 卖出订单价格轮询更新
|
||||
- 添加 `price_updated` 字段到 `sell_match_record` 表,用于标记价格是否已更新
|
||||
- 创建 `OrderStatusUpdateService` 定时任务服务,每 5 秒轮询一次:
|
||||
- 更新卖出订单的实际成交价(通过 orderId 查询订单详情)
|
||||
- 清理已删除账户的订单记录
|
||||
- 支持加权平均价格计算,处理部分成交的订单
|
||||
- 添加 orderId 格式验证:非 0x 开头的直接标记为已更新,0x 开头的等待定时任务更新
|
||||
- 下单完成后不再立即查询价格,直接保存,等待定时任务更新
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复跟单卖出订单的 API 凭证解密问题
|
||||
- 修复 `processSellTrade` 中 API 凭证未解密的问题,与 `processBuyTrade` 保持一致
|
||||
- 确保卖出订单能够正常使用 API 凭证进行认证
|
||||
|
||||
### 修复 SELL 订单精度问题
|
||||
- 修复 SELL 订单的 `makerAmount` 和 `takerAmount` 精度问题:
|
||||
- `makerAmount` (shares) 最多 2 位小数(符合 API 要求)
|
||||
- `takerAmount` (USDC) 最多 4 位小数(符合 API 要求)
|
||||
- 确保订单能够正常提交到 Polymarket API
|
||||
|
||||
## 📚 文档更新
|
||||
|
||||
- 添加 Docker 版本更新说明(中英文)
|
||||
- 添加链上 WebSocket 监听策略文档
|
||||
- 添加跟单逻辑总结文档
|
||||
- 更新部署文档,包含详细的版本更新步骤
|
||||
|
||||
## 🔧 技术改进
|
||||
|
||||
- 使用 Gson 替换 ObjectMapper,提高 JSON 解析稳定性
|
||||
- `JsonRpcResponse.result` 使用 `JsonElement` 类型,支持灵活的 JSON 结构
|
||||
- 优化 WebSocket 连接管理,减少不必要的连接
|
||||
- 添加线程安全机制,使用 Kotlin Coroutines Mutex
|
||||
- 启用 Spring 定时任务功能(`@EnableScheduling`)
|
||||
|
||||
## 📦 数据库变更
|
||||
|
||||
- 新增 `price_updated` 字段到 `sell_match_record` 表(Migration: V13)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.1
|
||||
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.0...v1.1.1
|
||||
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
|
||||
|
||||
## 📊 统计信息
|
||||
|
||||
- **文件变更**: 32 个文件
|
||||
- **代码变更**: +1872 行 / -1503 行
|
||||
- **主要提交**: 7 个提交
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
**请务必使用官方 Docker 镜像源,避免财产损失!**
|
||||
|
||||
### ✅ 官方 Docker Hub 镜像
|
||||
|
||||
**官方镜像地址**:`wrbug/polyhermes`
|
||||
|
||||
```bash
|
||||
# ✅ 正确:使用官方镜像
|
||||
docker pull wrbug/polyhermes:v1.1.1
|
||||
|
||||
# ❌ 错误:不要使用其他来源的镜像
|
||||
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
|
||||
```
|
||||
|
||||
### 🔗 官方渠道
|
||||
|
||||
请通过以下**唯一官方渠道**获取 PolyHermes:
|
||||
|
||||
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
* **Twitter**:@polyhermes
|
||||
* **Telegram 群组**:https://t.me/polyhermes
|
||||
|
||||
---
|
||||
|
||||
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
|
||||
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
# PolyHermes v2.0.0 Release Notes
|
||||
|
||||
## 🎉 重大更新
|
||||
|
||||
PolyHermes v2.0.0 是一个重要版本更新,带来了系统动态更新功能、优化的用户体验和多项技术改进。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 新功能
|
||||
|
||||
### 🔄 系统动态更新(核心功能)
|
||||
|
||||
**无需重启 Docker 容器即可更新系统**,大幅提升部署和维护效率。
|
||||
|
||||
- ✅ **在线更新**:在 Web UI 中一键检查并应用更新,无需手动操作
|
||||
- ✅ **零停机更新**:更新过程约 30-60 秒,系统自动处理,无需重启容器
|
||||
- ✅ **自动回滚**:更新失败时自动恢复到旧版本,确保系统稳定性
|
||||
- ✅ **版本管理**:清晰显示当前版本和可用更新,支持 Pre-release 版本检测
|
||||
- ✅ **更新内容展示**:支持 Markdown 格式的更新说明,美观易读
|
||||
|
||||
**技术特性**:
|
||||
- 独立的 Python Flask 更新服务(端口 9090),与主应用隔离
|
||||
- 单一更新包(tar.gz),包含前后端完整更新
|
||||
- 自动备份和版本管理
|
||||
- 管理员权限验证,确保安全性
|
||||
|
||||
### 📦 Release 管理工具
|
||||
|
||||
- **自动化发布脚本** (`create-release.sh`):
|
||||
- 自动创建 Git 标签
|
||||
- 发布 GitHub Release
|
||||
- 支持 Pre-release 标记
|
||||
- 自动拼接版本号后缀(Pre-release 自动添加 `-beta`)
|
||||
- 支持非交互模式,便于 CI/CD 集成
|
||||
|
||||
### 🎨 版本号显示优化
|
||||
|
||||
- **Tag 格式显示**:版本号使用 Git Tag 格式(如 `v2.0.0-beta`)
|
||||
- **智能颜色提示**:
|
||||
- 🟡 **黄色 Tag**:有新版本可用(点击可跳转到系统更新页面)
|
||||
- 🟢 **绿色 Tag**:当前已是最新版本
|
||||
- **镂空样式**:更小巧美观的版本号标签
|
||||
- **自动检查**:系统自动检查更新,有新版本时在导航栏显示提示
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI/UX 优化
|
||||
|
||||
### 系统更新页面
|
||||
|
||||
- **美化界面**:全新的渐变背景、卡片样式和图标设计
|
||||
- **Markdown 支持**:更新内容支持完整的 Markdown 渲染(标题、列表、代码块、表格等)
|
||||
- **进度显示**:美观的进度条和状态提示
|
||||
- **优化布局**:系统更新模块移至系统设置页面最上方,更易访问
|
||||
|
||||
### 版本号显示
|
||||
|
||||
- 使用镂空 Tag 样式,字号 8px
|
||||
- 与标题垂直居中对齐
|
||||
- 响应式设计,完美支持移动端和桌面端
|
||||
|
||||
---
|
||||
|
||||
## 🔧 技术改进
|
||||
|
||||
### 构建系统
|
||||
|
||||
- **修复 Docker 构建问题**:
|
||||
- 修复 `BUILD_IN_DOCKER=false` 时找不到前端产物的问题
|
||||
- 优化 `.dockerignore` 配置,确保外部构建产物可被使用
|
||||
- 修复 GitHub Actions 构建流程
|
||||
|
||||
- **版本号注入**:
|
||||
- 修复前端构建时版本号未正确传递的问题
|
||||
- 支持在构建时注入 Git Tag 和版本信息
|
||||
|
||||
- **Gradle Wrapper**:
|
||||
- 修复 GitHub Actions 构建错误
|
||||
- 正确配置 Gradle Wrapper JAR
|
||||
|
||||
### 代码质量
|
||||
|
||||
- 修复 TypeScript 编译错误
|
||||
- 清理未使用的导入和组件
|
||||
- 优化代码结构
|
||||
|
||||
---
|
||||
|
||||
## 📝 新增文档
|
||||
|
||||
- `docs/zh/DYNAMIC_UPDATE.md` - 动态更新技术方案文档
|
||||
- `docs/zh/DOCKER_VERSION.md` - Docker 版本管理说明
|
||||
- `docs/zh/DYNAMIC_UPDATE_CHECK.md` - 动态更新检查机制文档
|
||||
- `scripts/README_RELEASE.md` - Release 脚本使用说明
|
||||
- `scripts/CHANGELOG_TEMPLATE.md` - 更新日志模板
|
||||
|
||||
---
|
||||
|
||||
## 🔄 升级指南
|
||||
|
||||
### 从 v1.1.16 升级到 v2.0.0
|
||||
|
||||
#### 方式一:使用动态更新功能(推荐)
|
||||
|
||||
1. 登录系统,进入 **系统设置** → **系统更新**
|
||||
2. 点击 **检查更新**
|
||||
3. 如果有新版本,点击 **立即升级**
|
||||
4. 等待更新完成(约 30-60 秒)
|
||||
5. 页面会自动刷新,更新完成
|
||||
|
||||
#### 方式二:重新部署 Docker 容器
|
||||
|
||||
```bash
|
||||
# 1. 停止当前容器
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# 2. 拉取新版本镜像
|
||||
docker pull wrbug/polyhermes:v2.0.0
|
||||
|
||||
# 3. 更新 docker-compose.prod.yml 中的镜像标签
|
||||
# image: wrbug/polyhermes:v2.0.0
|
||||
|
||||
# 4. 重新启动
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
- ⚠️ **数据备份**:虽然更新不会删除数据,但建议在更新前备份数据库
|
||||
- ⚠️ **权限要求**:执行动态更新需要管理员权限
|
||||
- ✅ **向后兼容**:v2.0.0 完全兼容 v1.1.16 的数据结构和配置
|
||||
|
||||
---
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **新增文件**:17 个
|
||||
- **修改文件**:13 个
|
||||
- **代码变更**:+5251 行,-163 行
|
||||
|
||||
### 主要新增文件
|
||||
|
||||
- `docker/update-service.py` - 更新服务(Python Flask)
|
||||
- `frontend/src/pages/SystemUpdate.tsx` - 系统更新页面
|
||||
- `create-release.sh` - Release 创建脚本
|
||||
- `docs/zh/DYNAMIC_UPDATE.md` - 动态更新技术文档
|
||||
|
||||
---
|
||||
|
||||
## 🐛 修复的问题
|
||||
|
||||
- 修复 Docker 构建时找不到前端产物的问题
|
||||
- 修复前端构建时版本号未正确传递的问题
|
||||
- 修复 GitHub Actions 构建错误(Gradle Wrapper)
|
||||
- 修复 TypeScript 编译错误
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [动态更新技术方案](docs/zh/DYNAMIC_UPDATE.md)
|
||||
- [Docker 版本管理](docs/zh/DOCKER_VERSION.md)
|
||||
- [部署指南](docs/zh/DEPLOYMENT.md)
|
||||
- [Release 脚本使用说明](scripts/README_RELEASE.md)
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有使用 PolyHermes 的用户和贡献者!
|
||||
|
||||
---
|
||||
|
||||
**下载地址**:
|
||||
- Docker Hub: `wrbug/polyhermes:v2.0.0`
|
||||
- GitHub Releases: [v2.0.0](https://github.com/WrBug/PolyHermes/releases/tag/v2.0.0)
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
# PolyHermes v2.0.1 Release Notes
|
||||
|
||||
## 📋 版本信息
|
||||
- **版本号**: v2.0.1
|
||||
- **发布日期**: 2026-01-28
|
||||
- **基础版本**: v2.0.0
|
||||
|
||||
## 🎯 改动摘要
|
||||
|
||||
本次版本主要修复了订单状态检查、RPC 节点管理、订单金额计算等关键问题,提升了系统稳定性和用户体验。
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 1. 修复订单状态检查中缓存清除导致计时重置的问题
|
||||
|
||||
**问题描述**:
|
||||
- 订单详情为 `null` 时,缓存清除时机不当导致计时被重置
|
||||
- 订单超过 60 秒后无法正常删除
|
||||
- 部分卖出订单等待时间过长(之前等待 1 小时)
|
||||
|
||||
**修复内容**:
|
||||
- ✅ 修复缓存清除时机问题,避免计时被重置
|
||||
- ✅ 统一部分卖出和未成交订单的删除逻辑,都使用 60 秒重试窗口
|
||||
- ✅ 删除未使用的常量 `PARTIAL_SOLD_CLEANUP_WINDOW_MS`
|
||||
- ✅ 优化日志输出,区分部分卖出和未成交订单的日志信息
|
||||
|
||||
**影响范围**:
|
||||
- `OrderStatusUpdateService.kt` - 订单状态更新服务
|
||||
|
||||
**提交**: 7e87965
|
||||
|
||||
---
|
||||
|
||||
### 2. 修复禁用 RPC 节点后仍被使用的问题
|
||||
|
||||
**问题描述**:
|
||||
- 禁用 RPC 节点后,节点仍可能被系统使用
|
||||
- 节点状态更新不及时
|
||||
|
||||
**修复内容**:
|
||||
- ✅ 修复节点禁用逻辑,确保禁用后立即生效
|
||||
- ✅ 优化节点状态检查机制
|
||||
- ✅ 改进节点选择逻辑
|
||||
|
||||
**影响范围**:
|
||||
- `RpcNodeService.kt` - RPC 节点服务
|
||||
|
||||
**提交**: dd39e59
|
||||
|
||||
---
|
||||
|
||||
### 3. 修复订单金额计算和价格范围验证问题
|
||||
|
||||
**问题描述**:
|
||||
- 订单金额计算可能存在精度问题
|
||||
- 价格范围验证逻辑不完善
|
||||
|
||||
**修复内容**:
|
||||
- ✅ 修复订单金额计算逻辑
|
||||
- ✅ 完善价格范围验证
|
||||
- ✅ 优化数值计算精度处理
|
||||
|
||||
**影响范围**:
|
||||
- `OrderSigningService.kt` - 订单签名服务
|
||||
|
||||
**提交**: 2b20f4b
|
||||
|
||||
---
|
||||
|
||||
### 4. 修复系统更新 API 路由和改进健康检查逻辑
|
||||
|
||||
**问题描述**:
|
||||
- 系统更新 API 路由可能存在问题
|
||||
- 健康检查逻辑需要优化
|
||||
|
||||
**修复内容**:
|
||||
- ✅ 修复系统更新 API 路由
|
||||
- ✅ 改进健康检查逻辑
|
||||
- ✅ 优化错误处理
|
||||
|
||||
**提交**: 4aa85a9
|
||||
|
||||
---
|
||||
|
||||
### 5. 修复 Docker 构建相关问题
|
||||
|
||||
**问题描述**:
|
||||
- 本地 Docker 构建时 `frontend/dist` 和 `backend/build` 不存在导致构建失败
|
||||
- Dockerfile 中前端产物复制逻辑有问题
|
||||
|
||||
**修复内容**:
|
||||
- ✅ 修复本地 Docker 构建时目录不存在的问题
|
||||
- ✅ 修复 Dockerfile 中前端产物复制逻辑
|
||||
- ✅ 优化构建流程
|
||||
|
||||
**影响范围**:
|
||||
- `Dockerfile` - Docker 构建文件
|
||||
- `deploy.sh` - 部署脚本
|
||||
|
||||
**提交**: 419c68c, 8889803
|
||||
|
||||
---
|
||||
|
||||
### 6. 补充缺失的多语言 key
|
||||
|
||||
**问题描述**:
|
||||
- 部分多语言 key 缺失,导致界面显示异常
|
||||
|
||||
**修复内容**:
|
||||
- ✅ 补充缺失的多语言 key
|
||||
- ✅ 完善多语言支持
|
||||
|
||||
**影响范围**:
|
||||
- `frontend/src/locales/` - 多语言文件
|
||||
|
||||
**提交**: 2efc04a
|
||||
|
||||
---
|
||||
|
||||
## 🔧 优化改进
|
||||
|
||||
### 优化过滤原因文案的数值显示格式
|
||||
|
||||
**改进内容**:
|
||||
- ✅ 优化过滤原因文案的数值显示格式
|
||||
- ✅ 新增日期工具函数,统一日期格式化
|
||||
- ✅ 提升用户体验
|
||||
|
||||
**影响范围**:
|
||||
- `CopyTradingFilterService.kt` - 跟单过滤服务
|
||||
- `DateUtils.kt` - 日期工具类(新增)
|
||||
|
||||
**提交**: e115d45
|
||||
|
||||
---
|
||||
|
||||
## 📊 文件变更统计
|
||||
|
||||
- **修改文件数**: 10+
|
||||
- **新增文件数**: 1 (DateUtils.kt)
|
||||
- **新增行数**: 200+
|
||||
- **删除行数**: 100+
|
||||
|
||||
---
|
||||
|
||||
## 🔄 升级建议
|
||||
|
||||
1. **直接部署**:无需特殊操作,直接部署即可
|
||||
2. **验证订单处理**:建议验证订单状态检查是否正常工作
|
||||
3. **检查 RPC 节点**:确认 RPC 节点状态管理是否正常
|
||||
4. **验证订单金额**:确认订单金额计算是否正确
|
||||
|
||||
---
|
||||
|
||||
## 📝 完整提交列表
|
||||
|
||||
- 7e87965 - fix: 修复订单状态检查中缓存清除导致计时重置的问题
|
||||
- e115d45 - refactor: 优化过滤原因文案的数值显示格式
|
||||
- dd39e59 - fix: 修复禁用RPC节点后仍被使用的问题
|
||||
- 2efc04a - fix: 补充缺失的多语言key
|
||||
- 2b20f4b - fix: 修复订单金额计算和价格范围验证问题
|
||||
- 419c68c - fix: 修复本地 Docker 构建时 frontend/dist 和 backend/build 不存在的问题
|
||||
- 8889803 - fix: 修复 Dockerfile 中前端产物复制逻辑
|
||||
- 4aa85a9 - fix: 修复系统更新 API 路由和改进健康检查逻辑
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有贡献者和用户的支持与反馈!
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
# PolyHermes v2.0.2 Release Notes
|
||||
|
||||
## 📋 版本信息
|
||||
- **版本号**: v2.0.2
|
||||
- **发布日期**: 2026-01-29
|
||||
- **基础版本**: v2.0.1
|
||||
|
||||
## 🎯 改动摘要
|
||||
|
||||
本次版本主要修复了买入订单金额精度问题,并优化了构建流程和通知机制。
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 1. 修复买入订单金额精度问题
|
||||
|
||||
**问题描述**:
|
||||
- 市场买入订单创建时出现 `invalid amounts` 错误
|
||||
- Polymarket API 要求市场买入订单的 makerAmount 最多 2 位小数,takerAmount 最多 4 位小数
|
||||
- 之前的实现不符合 API 要求,导致订单创建失败
|
||||
|
||||
**修复内容**:
|
||||
- ✅ 修复市场买入订单 makerAmount 和 takerAmount 的精度限制
|
||||
- ✅ makerAmount (USDC) 限制为最多 2 位小数(之前为 4 位)
|
||||
- ✅ takerAmount (shares) 限制为最多 4 位小数(之前为 2 位)
|
||||
- ✅ 符合 Polymarket API 的要求,解决 'invalid amounts' 错误
|
||||
|
||||
**影响范围**:
|
||||
- `OrderSigningService.kt` - 订单签名服务
|
||||
|
||||
**提交**: 42472f6
|
||||
|
||||
---
|
||||
|
||||
## 🔧 优化改进
|
||||
|
||||
### 优化构建流程和通知机制
|
||||
|
||||
**改进内容**:
|
||||
- ✅ 添加 workflow_dispatch 支持手动触发构建
|
||||
- ✅ Release 事件默认只打包产物(在线升级),不构建 Docker
|
||||
- ✅ 手动触发可选择构建类型:在线升级或 Docker 升级
|
||||
- ✅ 优化 Telegram 通知文案,区分构建类型和升级路径
|
||||
- ✅ 在线升级任务不发送开始通知,只发送完成通知
|
||||
- ✅ 手动触发时产物上传为 Artifact,Release 事件上传到 Release Assets
|
||||
|
||||
**影响范围**:
|
||||
- `.github/workflows/docker-build.yml` - GitHub Actions 工作流
|
||||
|
||||
**提交**: e5992b5
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 潜在问题和注意事项
|
||||
|
||||
### 1. 买入订单金额精度调整可能带来的影响
|
||||
|
||||
**问题说明**:
|
||||
本次修复调整了买入订单的金额精度限制,可能会带来以下影响:
|
||||
|
||||
#### 1.1 makerAmount 精度降低(4 位 → 2 位小数)
|
||||
|
||||
**影响**:
|
||||
- **订单金额可能被向下舍入**:由于 makerAmount (USDC) 从 4 位小数降低到 2 位小数,订单金额可能会被向下舍入
|
||||
- **实际支付金额可能略低于预期**:例如,如果计算出的金额是 `0.5985 USDC`,现在会被舍入为 `0.59 USDC`,实际支付金额可能比预期少 `0.0085 USDC`
|
||||
- **可能影响固定金额模式的跟单**:在固定金额模式下,如果金额被舍入,实际买入的数量可能会略少于预期
|
||||
|
||||
**建议**:
|
||||
- 在固定金额模式下,建议设置稍大一点的金额,以补偿可能的舍入损失
|
||||
- 监控订单创建情况,确认金额是否符合预期
|
||||
- 如果发现金额差异较大,可以适当调整跟单配置
|
||||
|
||||
#### 1.2 takerAmount 精度提高(2 位 → 4 位小数)
|
||||
|
||||
**影响**:
|
||||
- **订单数量精度提高**:takerAmount (shares) 从 2 位小数提高到 4 位小数,可以更精确地指定买入数量
|
||||
- **可能增加订单复杂度**:更高的精度可能导致一些边缘情况,需要确保数量计算正确
|
||||
|
||||
**建议**:
|
||||
- 验证订单数量是否符合预期
|
||||
- 确认跟单数量计算逻辑是否正确
|
||||
|
||||
#### 1.3 订单创建成功率变化
|
||||
|
||||
**影响**:
|
||||
- **修复前**:订单创建可能因为精度问题失败
|
||||
- **修复后**:订单创建成功率应该提高,但金额可能略有变化
|
||||
|
||||
**建议**:
|
||||
- 升级后监控订单创建成功率
|
||||
- 对比升级前后的订单金额,确认差异是否在可接受范围内
|
||||
|
||||
### 2. 构建流程变更
|
||||
|
||||
**影响**:
|
||||
- Release 事件默认只打包产物,不构建 Docker
|
||||
- 如果需要 Docker 镜像,需要手动触发构建并选择 Docker 升级类型
|
||||
|
||||
**建议**:
|
||||
- 了解新的构建流程,根据需要选择合适的构建方式
|
||||
- 如果需要 Docker 镜像,使用 workflow_dispatch 手动触发
|
||||
|
||||
---
|
||||
|
||||
## 📊 文件变更统计
|
||||
|
||||
- **修改文件数**: 2
|
||||
- **新增行数**: 127
|
||||
- **删除行数**: 27
|
||||
|
||||
---
|
||||
|
||||
## 🔄 升级建议
|
||||
|
||||
1. **测试买入订单创建**:
|
||||
- 升级后先测试少量买入订单,确认金额和数量是否符合预期
|
||||
- 特别关注固定金额模式的跟单,确认金额是否被正确舍入
|
||||
|
||||
2. **监控订单创建成功率**:
|
||||
- 升级后监控订单创建成功率,确认是否解决了 `invalid amounts` 错误
|
||||
- 对比升级前后的订单金额,确认差异是否在可接受范围内
|
||||
|
||||
3. **验证金额计算**:
|
||||
- 验证 makerAmount 是否被正确限制为 2 位小数
|
||||
- 验证 takerAmount 是否被正确限制为 4 位小数
|
||||
|
||||
4. **了解构建流程变更**:
|
||||
- 了解新的构建流程,根据需要选择合适的构建方式
|
||||
- 如果需要 Docker 镜像,使用 workflow_dispatch 手动触发
|
||||
|
||||
---
|
||||
|
||||
## 📝 完整提交列表
|
||||
|
||||
- 42472f6 - fix: 修复买入订单金额精度问题
|
||||
- e5992b5 - feat(workflow): 优化构建流程和通知机制
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有贡献者和用户的支持与反馈!
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
# PolyHermes v2.0.3 Release Notes
|
||||
|
||||
## 📋 版本信息
|
||||
- **版本号**: v2.0.3
|
||||
- **发布日期**: 2026-01-31
|
||||
- **基础版本**: v2.0.2
|
||||
|
||||
## 🎯 改动摘要
|
||||
|
||||
本次版本主要优化了用户界面显示,包括数值格式化、Leader列表优化、移除不必要的配置项,提升了用户体验。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 新功能
|
||||
|
||||
### 1. 为所有数值显示添加千分位分隔符
|
||||
|
||||
**功能描述**:
|
||||
- ✅ 重构 `formatNumber` 和 `formatUSDC` 函数,默认添加千分位分隔符
|
||||
- ✅ 所有数值(金额、数量、价格等)现在默认显示千分位
|
||||
- ✅ 自动去除尾随零,提升可读性
|
||||
- ✅ 示例:`1234567.89` 显示为 `1,234,567.89`
|
||||
|
||||
**影响范围**:
|
||||
- `frontend/src/utils/index.ts` - 工具函数
|
||||
- `frontend/src/pages/Statistics.tsx` - 统计页面
|
||||
- `frontend/src/pages/CopyTradingStatistics.tsx` - 跟单统计页面
|
||||
- `frontend/src/pages/PositionList.tsx` - 持仓列表
|
||||
- `frontend/src/pages/AccountList.tsx` - 账户列表
|
||||
|
||||
**提交**: 40081c2
|
||||
|
||||
---
|
||||
|
||||
### 2. 在创建跟单配置时显示Leader资产信息
|
||||
|
||||
**功能描述**:
|
||||
- ✅ 选择Leader后自动获取并显示资产信息
|
||||
- ✅ 显示总资产、可用余额、仓位资产
|
||||
- ✅ 使用Card和Statistic组件美观展示
|
||||
- ✅ 支持中英文多语言
|
||||
- ✅ 使用formatUSDC格式化显示金额
|
||||
|
||||
**影响范围**:
|
||||
- `frontend/src/pages/CopyTradingOrders/AddModal.tsx` - 添加跟单配置弹窗
|
||||
- `frontend/src/pages/CopyTradingOrders/EditModal.tsx` - 编辑跟单配置弹窗
|
||||
- `frontend/src/locales/**/common.json` - 多语言文件
|
||||
|
||||
**提交**: 390b3ee
|
||||
|
||||
---
|
||||
|
||||
### 3. Leader列表显示仓位资产
|
||||
|
||||
**功能描述**:
|
||||
- ✅ Leader列表新增仓位资产显示
|
||||
- ✅ 显示Leader的总资产、可用余额、仓位资产
|
||||
- ✅ 优化资产信息展示方式
|
||||
|
||||
**影响范围**:
|
||||
- `frontend/src/pages/LeaderList.tsx` - Leader列表页面
|
||||
- `backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderDto.kt` - Leader DTO
|
||||
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaders/LeaderService.kt` - Leader服务
|
||||
|
||||
**提交**: 3350039
|
||||
|
||||
---
|
||||
|
||||
## 🔧 优化改进
|
||||
|
||||
### 1. Leader列表优化
|
||||
|
||||
**改进内容**:
|
||||
- ✅ 后端过滤价值为0的仓位
|
||||
- ✅ 持仓列表显示市场名称而非ID
|
||||
- ✅ 列表移除分类和创建时间列
|
||||
- ✅ 文案'跟单关系数'改为'跟单数'
|
||||
- ✅ 持仓DTO添加title字段
|
||||
|
||||
**影响范围**:
|
||||
- `frontend/src/pages/LeaderList.tsx` - Leader列表页面
|
||||
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaders/LeaderService.kt` - Leader服务
|
||||
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt` - 区块链服务
|
||||
|
||||
**提交**: 0bdc0c7
|
||||
|
||||
---
|
||||
|
||||
### 2. 列表只显示可用余额
|
||||
|
||||
**改进内容**:
|
||||
- ✅ 账户列表和Leader列表只显示可用余额
|
||||
- ✅ 简化界面,减少信息冗余
|
||||
|
||||
**影响范围**:
|
||||
- `frontend/src/pages/AccountList.tsx` - 账户列表
|
||||
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt` - 账户服务
|
||||
|
||||
**提交**: 17eea01
|
||||
|
||||
---
|
||||
|
||||
### 3. 移除仓位资产列
|
||||
|
||||
**改进内容**:
|
||||
- ✅ 移除不必要的仓位资产列显示
|
||||
- ✅ 简化界面布局
|
||||
|
||||
**影响范围**:
|
||||
- `frontend/src/pages/PositionList.tsx` - 持仓列表
|
||||
|
||||
**提交**: 6980781
|
||||
|
||||
---
|
||||
|
||||
## 🗑️ 移除功能
|
||||
|
||||
### 移除跟单最大仓位数量(maxPositionCount)配置
|
||||
|
||||
**移除原因**:
|
||||
- 该配置项使用频率低,且增加了系统复杂度
|
||||
- 简化跟单配置,提升用户体验
|
||||
|
||||
**移除内容**:
|
||||
- ✅ 数据库:创建迁移文件 V26 删除 `max_position_count` 字段
|
||||
- ✅ 后端:移除实体类、DTO、服务中的 `maxPositionCount` 相关代码
|
||||
- ✅ 后端:移除 `FilterResult` 中的 `FAILED_MAX_POSITION_COUNT` 状态
|
||||
- ✅ 后端:移除 `CopyTradingFilterService` 中的最大仓位数量检查逻辑
|
||||
- ✅ 前端:移除类型定义、表单字段和国际化翻译
|
||||
- ✅ 前端:移除过滤订单列表中的 `MAX_POSITION_COUNT` 类型
|
||||
|
||||
**影响范围**:
|
||||
- `backend/src/main/resources/db/migration/V26__remove_max_position_count.sql` - 数据库迁移
|
||||
- `backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt` - 实体类
|
||||
- `backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt` - DTO
|
||||
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt` - 过滤服务
|
||||
- `frontend/src/pages/CopyTradingOrders/AddModal.tsx` - 添加表单
|
||||
- `frontend/src/pages/CopyTradingOrders/EditModal.tsx` - 编辑表单
|
||||
- `frontend/src/types/index.ts` - 类型定义
|
||||
|
||||
**提交**: e8fd1b5
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复TypeScript类型错误
|
||||
|
||||
**修复内容**:
|
||||
- ✅ 修复编译时的TypeScript类型错误
|
||||
- ✅ 修复Spin导入问题
|
||||
- ✅ 修复Table fixed类型问题
|
||||
- ✅ 修复size类型问题
|
||||
|
||||
**影响范围**:
|
||||
- `frontend/src/pages/LeaderList.tsx` - Leader列表页面
|
||||
|
||||
**提交**: 8097660
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 潜在问题和注意事项
|
||||
|
||||
### 1. 数值格式化变更
|
||||
|
||||
**影响**:
|
||||
- 所有数值现在默认显示千分位分隔符
|
||||
- 如果之前有代码依赖特定的数值格式,可能需要调整
|
||||
|
||||
**建议**:
|
||||
- 检查是否有代码依赖特定的数值格式
|
||||
- 确认数值显示是否符合预期
|
||||
|
||||
### 2. 移除maxPositionCount配置
|
||||
|
||||
**影响**:
|
||||
- 如果之前使用了最大仓位数量限制功能,升级后将不再可用
|
||||
- 需要手动调整跟单策略
|
||||
|
||||
**建议**:
|
||||
- 升级前检查是否有跟单配置使用了最大仓位数量限制
|
||||
- 如有需要,可以手动调整跟单策略
|
||||
|
||||
### 3. Leader列表显示变更
|
||||
|
||||
**影响**:
|
||||
- Leader列表现在只显示价值大于0的仓位
|
||||
- 列表布局和显示内容有所调整
|
||||
|
||||
**建议**:
|
||||
- 升级后检查Leader列表显示是否符合预期
|
||||
- 确认仓位信息是否正确显示
|
||||
|
||||
---
|
||||
|
||||
## 📊 文件变更统计
|
||||
|
||||
- **修改文件数**: 28
|
||||
- **新增行数**: 1490
|
||||
- **删除行数**: 897
|
||||
|
||||
---
|
||||
|
||||
## 🔄 升级建议
|
||||
|
||||
1. **检查数值显示**:
|
||||
- 升级后检查所有数值显示是否符合预期
|
||||
- 确认千分位分隔符显示正确
|
||||
|
||||
2. **检查跟单配置**:
|
||||
- 如果有使用最大仓位数量限制的配置,需要手动调整
|
||||
- 确认跟单功能正常工作
|
||||
|
||||
3. **检查Leader列表**:
|
||||
- 升级后检查Leader列表显示是否正确
|
||||
- 确认仓位信息是否完整
|
||||
|
||||
4. **数据库迁移**:
|
||||
- 升级时会自动执行数据库迁移 V26
|
||||
- 迁移会删除 `max_position_count` 字段
|
||||
- 建议在升级前备份数据库
|
||||
|
||||
---
|
||||
|
||||
## 📝 完整提交列表
|
||||
|
||||
- 40081c2 - feat: 为所有数值显示添加千分位分隔符
|
||||
- e8fd1b5 - 移除跟单最大仓位数量(maxPositionCount)配置
|
||||
- 390b3ee - feat: 在创建跟单配置时显示Leader资产信息
|
||||
- 8097660 - fix: 修复TypeScript类型错误
|
||||
- 17eea01 - refactor: 列表只显示可用余额
|
||||
- 6980781 - refactor: 移除仓位资产列
|
||||
- 3350039 - feat: Leader列表显示仓位资产
|
||||
- 0bdc0c7 - feat: Leader列表优化
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有贡献者和用户的支持与反馈!
|
||||
|
||||
@@ -41,6 +41,17 @@ interface BuilderRelayerApi {
|
||||
@Query("address") address: String,
|
||||
@Query("type") type: String
|
||||
): 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>
|
||||
|
||||
/**
|
||||
* 获取交易状态
|
||||
@@ -96,6 +107,7 @@ interface BuilderRelayerApi {
|
||||
/**
|
||||
* 签名参数
|
||||
* 参考: builder-relayer-client/src/types.ts 的 SignatureParams
|
||||
* Safe 使用 operation/safeTxnGas/baseGas 等,PROXY 使用 relayHub/relay/relayerFee 等
|
||||
*/
|
||||
data class SignatureParams(
|
||||
@SerializedName("gasPrice")
|
||||
@@ -114,7 +126,19 @@ interface BuilderRelayerApi {
|
||||
val gasToken: String? = null,
|
||||
|
||||
@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
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -142,6 +166,17 @@ interface BuilderRelayerApi {
|
||||
@SerializedName("nonce")
|
||||
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 交易详情
|
||||
|
||||
+54
-7
@@ -23,6 +23,50 @@ class AccountController(
|
||||
|
||||
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 ->
|
||||
logger.error("导入账户失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.PARAM_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
is IllegalArgumentException -> if (e.message == "ACCOUNT_ALREADY_EXISTS") {
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.ACCOUNT_ALREADY_EXISTS, messageSource = messageSource))
|
||||
} else {
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.PARAM_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -221,5 +221,36 @@ class BacktestController(
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,37 @@ data class AccountImportRequest(
|
||||
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个)
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户更新请求
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,9 @@ data class BacktestCreateRequest(
|
||||
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开始)
|
||||
)
|
||||
|
||||
@@ -80,6 +83,14 @@ data class BacktestRetryRequest(
|
||||
val id: Long // 回测任务ID
|
||||
)
|
||||
|
||||
/**
|
||||
* 按当前配置重新测试请求(仅支持已完成任务)
|
||||
*/
|
||||
data class BacktestRerunRequest(
|
||||
val id: Long, // 源回测任务ID
|
||||
val taskName: String? = null // 新任务名称,为空时使用「原名称 (副本)」
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务列表响应
|
||||
*/
|
||||
@@ -156,7 +167,10 @@ data class BacktestConfigDto(
|
||||
val maxDailyOrders: Int,
|
||||
val supportSell: Boolean,
|
||||
val keywordFilterMode: String?,
|
||||
val keywords: List<String>?
|
||||
val keywords: List<String>?,
|
||||
val maxPositionValue: String?,
|
||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? // 最高价格(可选),NULL表示不限制最高价
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,7 @@ data class LeaderDto(
|
||||
val remark: String? = null, // Leader 备注(可选)
|
||||
val website: String? = null, // Leader 网站(可选)
|
||||
val copyTradingCount: Long = 0, // 跟单关系数量
|
||||
val backtestCount: Long = 0, // 回测数量
|
||||
val totalOrders: Long? = null, // 总订单数(可选)
|
||||
val totalPnl: String? = null, // 总盈亏(可选)
|
||||
val createdAt: Long,
|
||||
|
||||
@@ -16,11 +16,11 @@ data class Account(
|
||||
@Column(name = "private_key", nullable = false, length = 500)
|
||||
val privateKey: String, // 私钥(AES 加密存储)
|
||||
|
||||
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
|
||||
val walletAddress: String, // 钱包地址(从私钥推导)
|
||||
@Column(name = "wallet_address", nullable = false, length = 42)
|
||||
val walletAddress: String, // 钱包地址(从私钥推导),同一 EOA 可有多个账户(不同代理类型)
|
||||
|
||||
@Column(name = "proxy_address", nullable = false, length = 42)
|
||||
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
|
||||
@Column(name = "proxy_address", unique = true, nullable = false, length = 42)
|
||||
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须),唯一
|
||||
|
||||
@Column(name = "api_key", length = 500)
|
||||
val apiKey: String? = null, // Polymarket API Key(可选,明文存储)
|
||||
|
||||
@@ -37,7 +37,7 @@ data class BacktestTask(
|
||||
val backtestDays: Int,
|
||||
|
||||
@Column(name = "start_time", nullable = false)
|
||||
val startTime: Long, // 回测开始时间(历史时间)
|
||||
val startTime: Long, // 回测开始时间(历史时间),创建时计算;执行时以当前时间为基准用局部变量重算窗口
|
||||
|
||||
@Column(name = "end_time")
|
||||
var endTime: Long? = null, // 回测结束时间(历史时间)
|
||||
@@ -73,6 +73,15 @@ data class BacktestTask(
|
||||
@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, // 平均持仓时间(毫秒)
|
||||
|
||||
@@ -239,6 +239,7 @@ enum class ErrorCode(
|
||||
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"),
|
||||
@@ -248,7 +249,8 @@ enum class ErrorCode(
|
||||
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_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed"),
|
||||
SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_failed");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,5 +29,10 @@ interface AccountRepository : JpaRepository<Account, Long> {
|
||||
* 检查钱包地址是否存在
|
||||
*/
|
||||
fun existsByWalletAddress(walletAddress: String): Boolean
|
||||
|
||||
/**
|
||||
* 检查代理地址是否存在
|
||||
*/
|
||||
fun existsByProxyAddress(proxyAddress: String): Boolean
|
||||
}
|
||||
|
||||
|
||||
+223
-28
@@ -3,10 +3,12 @@ package com.wrbug.polymarketbot.service.accounts
|
||||
import com.wrbug.polymarketbot.api.TradeResponse
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.enums.WalletType
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.eq
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.util.getEventSlug
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
@@ -66,11 +68,6 @@ class AccountService(
|
||||
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
|
||||
}
|
||||
|
||||
// 2. 检查地址是否已存在
|
||||
if (accountRepository.existsByWalletAddress(request.walletAddress)) {
|
||||
return Result.failure(IllegalArgumentException("该钱包地址已存在"))
|
||||
}
|
||||
|
||||
// 3. 验证私钥和地址的对应关系
|
||||
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
|
||||
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
|
||||
@@ -104,7 +101,8 @@ class AccountService(
|
||||
// 5. 获取代理地址(必须成功,否则导入失败)
|
||||
// 根据用户选择的钱包类型计算代理地址
|
||||
val proxyAddress = runBlocking {
|
||||
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, request.walletType)
|
||||
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
|
||||
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, walletTypeEnum)
|
||||
if (proxyResult.isSuccess) {
|
||||
val address = proxyResult.getOrNull()
|
||||
if (address != null) {
|
||||
@@ -120,25 +118,31 @@ class AccountService(
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 按代理地址去重:该代理地址已存在则不允许重复导入
|
||||
if (accountRepository.existsByProxyAddress(proxyAddress)) {
|
||||
return Result.failure(IllegalArgumentException("ACCOUNT_ALREADY_EXISTS"))
|
||||
}
|
||||
|
||||
// 7. 加密敏感信息
|
||||
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
|
||||
val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) }
|
||||
val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) }
|
||||
|
||||
// 8. 生成账户名称(如果未提供,使用钱包地址后四位)
|
||||
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
|
||||
val accountName = if (request.accountName.isNullOrBlank()) {
|
||||
val walletAddress = request.walletAddress.trim()
|
||||
// 取地址后四位(去掉 0x 前缀后取后四位)
|
||||
val addressWithoutPrefix = if (walletAddress.startsWith("0x") || walletAddress.startsWith("0X")) {
|
||||
walletAddress.substring(2)
|
||||
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
|
||||
val typeLabel = walletTypeEnum.name.uppercase()
|
||||
val proxyWithoutPrefix = if (proxyAddress.startsWith("0x") || proxyAddress.startsWith("0X")) {
|
||||
proxyAddress.substring(2)
|
||||
} else {
|
||||
walletAddress
|
||||
proxyAddress
|
||||
}
|
||||
if (addressWithoutPrefix.length >= 4) {
|
||||
addressWithoutPrefix.substring(addressWithoutPrefix.length - 4).uppercase()
|
||||
val suffix = if (proxyWithoutPrefix.length >= 4) {
|
||||
proxyWithoutPrefix.substring(proxyWithoutPrefix.length - 4).uppercase()
|
||||
} else {
|
||||
addressWithoutPrefix.uppercase()
|
||||
proxyWithoutPrefix.uppercase()
|
||||
}
|
||||
"$typeLabel-$suffix"
|
||||
} else {
|
||||
request.accountName.trim()
|
||||
}
|
||||
@@ -171,6 +175,192 @@ class AccountService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理地址选项(用于账户导入前选择代理类型)
|
||||
* 私钥导入:返回 Magic 和 Safe 两个选项
|
||||
* 助记词导入:仅返回 Safe 选项
|
||||
*/
|
||||
suspend fun checkProxyOptions(request: CheckProxyOptionsRequest): Result<CheckProxyOptionsResponse> {
|
||||
return try {
|
||||
// 1. 验证钱包地址格式
|
||||
if (!isValidWalletAddress(request.walletAddress)) {
|
||||
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
|
||||
}
|
||||
|
||||
// 2. 验证至少提供了私钥或助记词之一
|
||||
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
|
||||
return Result.failure(IllegalArgumentException("必须提供私钥或助记词"))
|
||||
}
|
||||
|
||||
val options = mutableListOf<ProxyOptionDto>()
|
||||
|
||||
// 3. 判断导入类型
|
||||
val isPrivateKeyImport = !request.privateKey.isNullOrBlank()
|
||||
|
||||
if (isPrivateKeyImport) {
|
||||
// 私钥导入:并行获取 Magic 和 Safe 代理地址及资产
|
||||
coroutineScope {
|
||||
val magicDeferred = async {
|
||||
try {
|
||||
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.MAGIC).getOrNull()
|
||||
if (proxyAddress != null) {
|
||||
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
|
||||
ProxyOptionDto(
|
||||
walletType = WalletType.MAGIC.value,
|
||||
proxyAddress = proxyAddress,
|
||||
descriptionKey = "accountImport.proxyOption.magic.description",
|
||||
availableBalance = balance?.availableBalance ?: "0",
|
||||
positionBalance = balance?.positionBalance ?: "0",
|
||||
totalBalance = balance?.totalBalance ?: "0",
|
||||
positionCount = balance?.positions?.size ?: 0,
|
||||
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positions?.isNotEmpty() == true),
|
||||
error = null
|
||||
)
|
||||
} else {
|
||||
ProxyOptionDto(
|
||||
walletType = "magic",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.magic.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取 Magic 代理地址失败"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取 Magic 代理地址或资产失败: ${e.message}", e)
|
||||
ProxyOptionDto(
|
||||
walletType = "magic",
|
||||
proxyAddress = blockchainService.calculateMagicProxyAddress(request.walletAddress),
|
||||
descriptionKey = "accountImport.proxyOption.magic.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取资产信息失败: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val safeDeferred = async {
|
||||
try {
|
||||
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
|
||||
if (proxyAddress != null) {
|
||||
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
|
||||
ProxyOptionDto(
|
||||
walletType = WalletType.SAFE.value,
|
||||
proxyAddress = proxyAddress,
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = balance?.availableBalance ?: "0",
|
||||
positionBalance = balance?.positionBalance ?: "0",
|
||||
totalBalance = balance?.totalBalance ?: "0",
|
||||
positionCount = balance?.positions?.size ?: 0,
|
||||
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positions?.isNotEmpty() == true),
|
||||
error = null
|
||||
)
|
||||
} else {
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取 Safe 代理地址失败"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取资产信息失败: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val magicOption = magicDeferred.await()
|
||||
val safeOption = safeDeferred.await()
|
||||
// Safe 在前,Magic 在后
|
||||
options.add(safeOption)
|
||||
options.add(magicOption)
|
||||
}
|
||||
} else {
|
||||
// 助记词导入:仅获取 Safe 代理地址及资产
|
||||
try {
|
||||
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
|
||||
if (proxyAddress != null) {
|
||||
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
|
||||
options.add(
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = proxyAddress,
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = balance?.availableBalance ?: "0",
|
||||
positionBalance = balance?.positionBalance ?: "0",
|
||||
totalBalance = balance?.totalBalance ?: "0",
|
||||
positionCount = balance?.positions?.size ?: 0,
|
||||
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positions?.isNotEmpty() == true),
|
||||
error = null
|
||||
)
|
||||
)
|
||||
} else {
|
||||
options.add(
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取 Safe 代理地址失败"
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
|
||||
options.add(
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取资产信息失败: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(CheckProxyOptionsResponse(options = options))
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查代理地址选项失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息
|
||||
*/
|
||||
@@ -827,7 +1017,7 @@ class AccountService(
|
||||
"0"
|
||||
}
|
||||
|
||||
// 11. 创建并签名订单(使用计算后的卖出数量)
|
||||
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType)
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedPrivateKey,
|
||||
@@ -836,7 +1026,7 @@ class AccountService(
|
||||
side = "SELL",
|
||||
price = sellPrice,
|
||||
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
|
||||
signatureType = 2, // Browser Wallet(与正确订单数据一致)
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = expiration
|
||||
@@ -1189,13 +1379,6 @@ class AccountService(
|
||||
*/
|
||||
suspend fun redeemPositions(request: PositionRedeemRequest): Result<PositionRedeemResponse> {
|
||||
return try {
|
||||
// 检查 Builder API Key 是否已配置
|
||||
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
||||
return Result.failure(
|
||||
IllegalStateException("Builder API Key 未配置,无法执行 Gasless 交易。请前往系统设置页面配置 Builder API Key。")
|
||||
)
|
||||
}
|
||||
|
||||
if (request.positions.isEmpty()) {
|
||||
return Result.failure(IllegalArgumentException("赎回仓位列表不能为空"))
|
||||
}
|
||||
@@ -1217,7 +1400,17 @@ class AccountService(
|
||||
accounts[accountId] = account
|
||||
}
|
||||
|
||||
// 4. 验证并收集要赎回的仓位信息(按账户分组)
|
||||
// 4. 若涉及 Magic 账户,必须已配置 Builder API Key(提前判断,避免执行到深层再报错)
|
||||
val hasMagicAccount = accounts.values.any {
|
||||
WalletType.fromStringOrDefault(it.walletType, WalletType.SAFE) == WalletType.MAGIC
|
||||
}
|
||||
if (hasMagicAccount && !relayClientService.isBuilderApiKeyConfigured()) {
|
||||
return Result.failure(
|
||||
IllegalStateException("Builder API Key 未配置,无法执行 Magic 账户赎回(Gasless)。请前往系统设置页面配置 Builder API Key。")
|
||||
)
|
||||
}
|
||||
|
||||
// 5. 验证并收集要赎回的仓位信息(按账户分组)
|
||||
val accountRedeemData = mutableMapOf<Long, MutableList<Pair<AccountPositionDto, BigInteger>>>()
|
||||
val accountRedeemedInfo =
|
||||
mutableMapOf<Long, MutableList<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>>()
|
||||
@@ -1260,7 +1453,7 @@ class AccountService(
|
||||
accountRedeemedInfo[accountId] = accountInfo
|
||||
}
|
||||
|
||||
// 5. 对每个账户执行赎回
|
||||
// 6. 对每个账户执行赎回(Safe 与 Magic 均支持,Magic 通过 Builder Relayer PROXY Gasless 执行)
|
||||
val accountTransactions = mutableListOf<com.wrbug.polymarketbot.dto.AccountRedeemTransaction>()
|
||||
var totalRedeemedValue = BigDecimal.ZERO
|
||||
|
||||
@@ -1280,11 +1473,13 @@ class AccountService(
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
// 调用区块链服务赎回仓位
|
||||
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||
val redeemResult = blockchainService.redeemPositions(
|
||||
privateKey = decryptedPrivateKey,
|
||||
proxyAddress = account.proxyAddress,
|
||||
conditionId = marketId,
|
||||
indexSets = indexSets
|
||||
indexSets = indexSets,
|
||||
walletType = walletTypeEnum
|
||||
)
|
||||
|
||||
redeemResult.fold(
|
||||
@@ -1315,7 +1510,7 @@ class AccountService(
|
||||
)
|
||||
}
|
||||
|
||||
// 6. 发送赎回推送通知(异步,不阻塞)
|
||||
// 7. 发送赎回推送通知(异步,不阻塞)
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取当前语言设置
|
||||
|
||||
+12
-19
@@ -430,27 +430,20 @@ class PositionCheckService(
|
||||
processedRedeemablePositions[positionKey] = now
|
||||
}
|
||||
|
||||
// 赎回成功后,再查找订单并更新订单状态
|
||||
// 赎回成功后,按每个跟单配置分别查找未卖出订单并更新状态
|
||||
// 同一账户同一市场可能同时跟多个 Leader,需按 copyTradingId 分别生成自动卖出记录(如 leader1 对应 20 share,leader2 对应 16 share)
|
||||
for (position in positionsToRedeem) {
|
||||
// 查找相同仓位的未卖出订单(remaining_quantity > 0)
|
||||
val unmatchedOrders = mutableListOf<CopyOrderTracking>()
|
||||
for (copyTrading in copyTradings) {
|
||||
if (position.outcomeIndex != null) {
|
||||
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
|
||||
copyTrading.id!!,
|
||||
position.marketId,
|
||||
position.outcomeIndex
|
||||
)
|
||||
unmatchedOrders.addAll(orders)
|
||||
}
|
||||
if (position.outcomeIndex == null) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果有未卖出订单,更新订单状态
|
||||
if (unmatchedOrders.isNotEmpty()) {
|
||||
// 从订单中获取 copyTradingId(所有订单应该有相同的 copyTradingId)
|
||||
val copyTradingId = unmatchedOrders.firstOrNull()?.copyTradingId
|
||||
if (copyTradingId != null) {
|
||||
updateOrdersAsSoldAfterRedeem(unmatchedOrders, position, copyTradingId)
|
||||
for (copyTrading in copyTradings) {
|
||||
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
|
||||
copyTrading.id!!,
|
||||
position.marketId,
|
||||
position.outcomeIndex
|
||||
)
|
||||
if (orders.isNotEmpty()) {
|
||||
updateOrdersAsSoldAfterRedeem(orders, position, copyTrading.id!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+45
-39
@@ -12,9 +12,19 @@ import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 基于 start 游标的一批历史交易结果
|
||||
* @param trades 本批交易列表(已按时间升序)
|
||||
* @param nextCursorSeconds 下一页游标(API 的 start 参数,秒级);若本批不足 limit 条则为 null 表示最后一页
|
||||
*/
|
||||
data class LeaderTradesBatchResult(
|
||||
val trades: List<TradeData>,
|
||||
val nextCursorSeconds: Long?
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测数据服务
|
||||
* 直接从 Polymarket Data API 获取 Leader 历史交易
|
||||
* 直接从 Polymarket Data API 获取 Leader 历史交易,使用 start 游标分页(避免 offset 过大报错)
|
||||
*/
|
||||
@Service
|
||||
class BacktestDataService(
|
||||
@@ -24,48 +34,45 @@ class BacktestDataService(
|
||||
private val logger = LoggerFactory.getLogger(BacktestDataService::class.java)
|
||||
|
||||
/**
|
||||
* 分页获取 Leader 历史交易(用于回测恢复)
|
||||
* 支持重试机制:最多重试5次,每次间隔1秒
|
||||
* 按 start 游标获取一批 Leader 历史交易
|
||||
* 规则:limit 固定为 500;若返回 500 条则取本批最大时间戳(秒)作为下一页 start,不加 1(同一秒可能多笔订单,由下游按 tradeId 去重);不足 500 则为最后一页
|
||||
*
|
||||
* @param leaderId Leader ID
|
||||
* @param startTime 开始时间(毫秒时间戳)
|
||||
* @param endTime 结束时间(毫秒时间戳)
|
||||
* @param page 页码 (从 0 开始)
|
||||
* @param size 每页数量
|
||||
* @return 历史交易列表
|
||||
* @throws Exception 重试5次后仍然失败时抛出异常
|
||||
* @param startTime 回测开始时间(毫秒)
|
||||
* @param endTime 回测结束时间(毫秒)
|
||||
* @param cursorStartSeconds 本页游标(API 的 start,秒);首次传 startTime/1000
|
||||
* @param limit 每批条数,建议 500
|
||||
* @return 本批交易与下一页游标(null 表示没有下一页)
|
||||
*/
|
||||
suspend fun getLeaderHistoricalTradesForPage(
|
||||
suspend fun getLeaderHistoricalTradesBatch(
|
||||
leaderId: Long,
|
||||
startTime: Long,
|
||||
endTime: Long,
|
||||
page: Int,
|
||||
size: Int
|
||||
): List<TradeData> {
|
||||
logger.info("分页获取 Leader 历史交易: leaderId=$leaderId, timeRange=$startTime - $endTime, page=$page, size=$size")
|
||||
cursorStartSeconds: Long,
|
||||
limit: Int
|
||||
): LeaderTradesBatchResult {
|
||||
logger.info("获取 Leader 历史交易批次: leaderId=$leaderId, cursorStart=$cursorStartSeconds, limit=$limit")
|
||||
|
||||
// 1. 验证 Leader 是否存在
|
||||
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||
?: throw IllegalArgumentException("Leader 不存在: $leaderId")
|
||||
|
||||
val dataApi = retrofitFactory.createDataApi()
|
||||
val offset = page * size
|
||||
val endSeconds = endTime / 1000
|
||||
val maxRetries = 5
|
||||
val retryDelay = 1000L // 1秒
|
||||
val retryDelay = 1000L
|
||||
|
||||
// 2. 重试机制:最多重试5次
|
||||
var lastException: Exception? = null
|
||||
for (attempt in 1..maxRetries) {
|
||||
try {
|
||||
val response = dataApi.getUserActivity(
|
||||
user = leader.leaderAddress,
|
||||
type = listOf("TRADE"),
|
||||
start = startTime / 1000,
|
||||
end = endTime / 1000,
|
||||
limit = size,
|
||||
offset = offset,
|
||||
sortBy = "timestamp",
|
||||
sortDirection = "asc"
|
||||
start = cursorStartSeconds,
|
||||
end = endSeconds,
|
||||
limit = limit,
|
||||
offset = null,
|
||||
sortBy = "TIMESTAMP",
|
||||
sortDirection = "ASC"
|
||||
)
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
@@ -73,25 +80,20 @@ class BacktestDataService(
|
||||
}
|
||||
|
||||
val activities = response.body()!!
|
||||
logger.info("成功获取第 $page 页数据,共 ${activities.size} 条交易(第 $attempt 次尝试)")
|
||||
logger.info("本批获取 ${activities.size} 条活动(第 $attempt 次尝试)")
|
||||
|
||||
return activities.mapNotNull { activity ->
|
||||
val trades = activities.mapNotNull { activity ->
|
||||
try {
|
||||
if (activity.type != "TRADE") {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
if (activity.type != "TRADE") return@mapNotNull null
|
||||
if (activity.side == null || activity.price == null || activity.size == null || activity.usdcSize == null) {
|
||||
logger.warn("活动数据缺少必要字段,跳过: activity=$activity")
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
val tradeTimestamp = activity.timestamp * 1000
|
||||
if (tradeTimestamp < startTime || tradeTimestamp > endTime) {
|
||||
logger.debug("交易时间超出范围,跳过: timestamp=$tradeTimestamp, range=$startTime - $endTime")
|
||||
logger.debug("交易时间超出范围,跳过: timestamp=$tradeTimestamp")
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
TradeData(
|
||||
tradeId = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}_${activity.side}",
|
||||
marketId = activity.conditionId,
|
||||
@@ -111,20 +113,24 @@ class BacktestDataService(
|
||||
}
|
||||
}
|
||||
|
||||
// 下一页 start 用本批最大 timestamp(秒),不加 1:同一秒可能有多笔订单,依赖下游按 tradeId 去重
|
||||
val nextCursorSeconds: Long? = if (trades.size < limit) {
|
||||
null
|
||||
} else {
|
||||
val maxTs = trades.maxOf { it.timestamp }
|
||||
maxTs / 1000
|
||||
}
|
||||
return LeaderTradesBatchResult(trades = trades, nextCursorSeconds = nextCursorSeconds)
|
||||
} catch (e: Exception) {
|
||||
lastException = e
|
||||
logger.warn("第 $attempt/$maxRetries 次尝试获取第 $page 页数据失败: ${e.message}")
|
||||
|
||||
// 如果不是最后一次尝试,则等待后重试
|
||||
logger.warn("第 $attempt/$maxRetries 次获取批次失败: ${e.message}")
|
||||
if (attempt < maxRetries) {
|
||||
logger.info("等待 $retryDelay 毫秒后重试...")
|
||||
delay(retryDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重试5次后仍然失败,抛出异常
|
||||
val errorMsg = "重试 $maxRetries 次后仍然失败获取第 $page 页数据"
|
||||
val errorMsg = "重试 $maxRetries 次后仍然失败,cursorStart=$cursorStartSeconds"
|
||||
logger.error(errorMsg, lastException)
|
||||
throw Exception(errorMsg, lastException)
|
||||
}
|
||||
|
||||
+176
-99
@@ -8,7 +8,9 @@ import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import com.wrbug.polymarketbot.repository.BacktestTradeRepository
|
||||
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
|
||||
import com.wrbug.polymarketbot.service.common.MarketPriceService
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterService
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -17,6 +19,7 @@ import java.math.BigDecimal
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Service
|
||||
class BacktestExecutionService(
|
||||
@@ -24,12 +27,14 @@ class BacktestExecutionService(
|
||||
private val backtestTradeRepository: BacktestTradeRepository,
|
||||
private val backtestDataService: BacktestDataService,
|
||||
private val marketPriceService: MarketPriceService,
|
||||
private val marketService: MarketService,
|
||||
private val copyTradingFilterService: CopyTradingFilterService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(BacktestExecutionService::class.java)
|
||||
|
||||
/**
|
||||
* 持仓数据结构
|
||||
* @param marketEndDate 市场结束时间(毫秒),用于到期结算判断,null 表示未知
|
||||
*/
|
||||
data class Position(
|
||||
val marketId: String,
|
||||
@@ -37,7 +42,8 @@ class BacktestExecutionService(
|
||||
val outcomeIndex: Int?,
|
||||
var quantity: BigDecimal,
|
||||
val avgPrice: BigDecimal,
|
||||
val leaderBuyQuantity: BigDecimal?
|
||||
val leaderBuyQuantity: BigDecimal?,
|
||||
val marketEndDate: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -66,6 +72,9 @@ class BacktestExecutionService(
|
||||
supportSell = task.supportSell,
|
||||
minOrderDepth = null, // 回测无实时订单簿数据
|
||||
maxSpread = null, // 回测无实时价差数据
|
||||
maxPositionValue = task.maxPositionValue,
|
||||
minPrice = task.minPrice, // 最低价格
|
||||
maxPrice = task.maxPrice, // 最高价格
|
||||
keywordFilterMode = task.keywordFilterMode,
|
||||
keywords = task.keywords,
|
||||
configName = null,
|
||||
@@ -80,10 +89,13 @@ class BacktestExecutionService(
|
||||
* 执行回测任务(支持分页和恢复)
|
||||
* 自动处理所有页面的数据,支持中断恢复
|
||||
*/
|
||||
/** 每批请求 API 的条数(基于 start 游标分页,避免 offset 过大) */
|
||||
private val backtestBatchLimit = 500
|
||||
|
||||
@Transactional
|
||||
suspend fun executeBacktest(task: BacktestTask, page: Int = 1, size: Int = 100) {
|
||||
try {
|
||||
logger.info("开始执行回测任务: taskId=${task.id}, taskName=${task.taskName}, startPage=$page, pageSize=$size")
|
||||
logger.info("开始执行回测任务: taskId=${task.id}, taskName=${task.taskName}, batchLimit=$backtestBatchLimit")
|
||||
|
||||
// 1. 更新任务状态为 RUNNING
|
||||
task.status = "RUNNING"
|
||||
@@ -95,99 +107,89 @@ class BacktestExecutionService(
|
||||
var currentBalance = task.initialBalance
|
||||
val positions = mutableMapOf<String, Position>()
|
||||
val trades = mutableListOf<BacktestTrade>()
|
||||
// 每日订单数缓存:key为日期字符串(yyyy-MM-dd),value为当天的 BUY 订单数
|
||||
val dailyOrderCountCache = mutableMapOf<String, Int>()
|
||||
// 每日亏损缓存:key为日期字符串(yyyy-MM-dd),value为当天的累计亏损金额
|
||||
val dailyLossCache = mutableMapOf<String, BigDecimal>()
|
||||
val seenTradeIds = mutableSetOf<String>()
|
||||
|
||||
// 3. 计算回测时间范围
|
||||
// 3. 回测时间范围:首次执行以当前时间为基准取最近 backtestDays 天;断点续跑保留原 startTime,仅 endTime 延到当前
|
||||
val endTime = System.currentTimeMillis()
|
||||
val startTime = task.startTime
|
||||
val startTime = if (task.lastProcessedTradeTime == null) {
|
||||
endTime - (task.backtestDays * 24L * 3600 * 1000)
|
||||
} else {
|
||||
task.startTime
|
||||
}
|
||||
|
||||
logger.info("回测时间范围: ${formatTimestamp(startTime)} - ${formatTimestamp(endTime)}, " +
|
||||
logger.info("回测时间范围: ${formatTimestamp(startTime)} - ${formatTimestamp(endTime)} (${task.backtestDays} 天), " +
|
||||
"初始余额: ${task.initialBalance.toPlainString()}")
|
||||
|
||||
// 4. 恢复机制:如果有恢复点,计算从哪一页开始(页码从 0 开始)
|
||||
val startPage = if (task.lastProcessedTradeIndex != null) {
|
||||
val lastProcessedIndex = task.lastProcessedTradeIndex!!
|
||||
// 计算已处理的页码(从 0 开始)
|
||||
val processedPage = lastProcessedIndex / size
|
||||
|
||||
// 特殊情况:如果lastProcessedTradeIndex刚好是100的倍数减1(比如99,199,299...)
|
||||
// 说明该页已经完全处理,应该从下一页开始
|
||||
val nextPage = if (lastProcessedIndex % size == size - 1) {
|
||||
processedPage + 1
|
||||
} else {
|
||||
processedPage
|
||||
}
|
||||
|
||||
logger.info("恢复任务:已处理索引=$lastProcessedIndex, 计算页码=$nextPage, size=$size")
|
||||
nextPage
|
||||
// 4. 游标分页:恢复时也从 lastProcessedTradeTime 所在秒开始拉(不加 1),与分页规则一致;已处理的通过 timestamp 跳过,不依赖内存 seenTradeIds
|
||||
var cursorSeconds = if (task.lastProcessedTradeTime != null) {
|
||||
task.lastProcessedTradeTime!! / 1000
|
||||
} else {
|
||||
logger.info("新任务:从第0页开始")
|
||||
0
|
||||
startTime / 1000
|
||||
}
|
||||
val endSeconds = endTime / 1000
|
||||
val resumeThresholdMs = task.lastProcessedTradeTime ?: 0L
|
||||
|
||||
// 5. 分页获取和处理交易数据
|
||||
var currentPage = maxOf(startPage, page)
|
||||
// 计算下一个要处理的全局索引(用于日志和统计)
|
||||
val nextGlobalIndex = if (task.lastProcessedTradeIndex != null) {
|
||||
task.lastProcessedTradeIndex!! + 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
logger.info("开始分页处理:起始页=$currentPage, 下一个要处理的索引=$nextGlobalIndex")
|
||||
logger.info("开始游标分页:cursorStart=$cursorSeconds(恢复则跳过 timestamp<=${resumeThresholdMs}ms)")
|
||||
|
||||
var terminateBacktest = false
|
||||
while (true) {
|
||||
// 定期从数据库重新加载任务状态,确保能及时响应停止操作
|
||||
if (terminateBacktest) {
|
||||
logger.info("余额已为负或不足,终止回测循环")
|
||||
break
|
||||
}
|
||||
val currentTaskStatus = backtestTaskRepository.findById(task.id!!).orElse(null)
|
||||
if (currentTaskStatus == null || currentTaskStatus.status != "RUNNING") {
|
||||
logger.info("回测任务状态已变更: ${currentTaskStatus?.status},停止执行")
|
||||
break
|
||||
}
|
||||
|
||||
logger.info("正在获取第 $currentPage 页数据...")
|
||||
logger.info("正在获取批次数据 cursorStart=$cursorSeconds (${formatTimestamp(cursorSeconds * 1000)}) ...")
|
||||
|
||||
// 每页使用独立的交易列表,避免跨页重复保存
|
||||
val currentPageTrades = mutableListOf<BacktestTrade>()
|
||||
|
||||
try {
|
||||
// 获取当前页的交易数据(支持重试5次)
|
||||
val pageTrades = backtestDataService.getLeaderHistoricalTradesForPage(
|
||||
val batch = backtestDataService.getLeaderHistoricalTradesBatch(
|
||||
task.leaderId,
|
||||
startTime,
|
||||
endTime,
|
||||
currentPage,
|
||||
size
|
||||
cursorSeconds,
|
||||
backtestBatchLimit
|
||||
)
|
||||
val pageTrades = batch.trades
|
||||
|
||||
if (pageTrades.isEmpty()) {
|
||||
logger.info("第 $currentPage 页无数据,所有数据处理完成")
|
||||
logger.info("本批无数据,所有数据处理完成")
|
||||
break
|
||||
}
|
||||
|
||||
logger.info("第 $currentPage 页获取到 ${pageTrades.size} 条交易")
|
||||
logger.info("本批获取 ${pageTrades.size} 条交易,是否有下一页: ${batch.nextCursorSeconds != null}")
|
||||
|
||||
// 处理当前页的交易
|
||||
val countAtBatchStart = task.processedTradeCount
|
||||
var lastProcessedIndexInPage: Int? = null
|
||||
var processedInBatch = 0
|
||||
for (localIndex in pageTrades.indices) {
|
||||
val leaderTrade = pageTrades[localIndex]
|
||||
// 计算当前交易在全局数据中的索引(从 0 开始)
|
||||
val index = currentPage * size + localIndex
|
||||
|
||||
// 如果是恢复任务,跳过已处理的条目
|
||||
if (task.lastProcessedTradeIndex != null && index <= task.lastProcessedTradeIndex!!) {
|
||||
logger.debug("跳过已处理的交易: index=$index, lastProcessedIndex=${task.lastProcessedTradeIndex}")
|
||||
if (leaderTrade.tradeId in seenTradeIds) {
|
||||
logger.debug("跳过重复交易: ${leaderTrade.tradeId}")
|
||||
continue
|
||||
}
|
||||
if (resumeThresholdMs > 0 && leaderTrade.timestamp <= resumeThresholdMs) {
|
||||
logger.debug("恢复时跳过已处理时间戳: tradeId=${leaderTrade.tradeId}, timestamp=${leaderTrade.timestamp}")
|
||||
continue
|
||||
}
|
||||
seenTradeIds.add(leaderTrade.tradeId)
|
||||
|
||||
// 记录当前处理的索引
|
||||
val index = countAtBatchStart + processedInBatch
|
||||
lastProcessedIndexInPage = index
|
||||
processedInBatch++
|
||||
|
||||
// 更新进度
|
||||
val progress = if (pageTrades.size > 0) {
|
||||
(localIndex * 100) / pageTrades.size
|
||||
// 进度按时间比例:(当前订单时间 - 开始时间) / (结束时间 - 开始时间) * 100,运行中上限 99
|
||||
val timeRange = endTime - startTime
|
||||
val progress = if (timeRange > 0) {
|
||||
val elapsed = (leaderTrade.timestamp - startTime).coerceIn(0L, timeRange)
|
||||
min(99, ((elapsed * 100) / timeRange).toInt())
|
||||
} else {
|
||||
0
|
||||
}
|
||||
@@ -199,15 +201,15 @@ class BacktestExecutionService(
|
||||
|
||||
try {
|
||||
// 5.1 实时检查并结算已到期的市场
|
||||
currentBalance = settleExpiredPositions(task, positions, currentBalance, trades, leaderTrade.timestamp)
|
||||
currentBalance = settleExpiredPositions(task, positions, currentBalance, trades, leaderTrade.timestamp, currentPageTrades)
|
||||
|
||||
// 5.2 检查余额和持仓状态
|
||||
if (currentBalance < BigDecimal.ZERO) {
|
||||
logger.info("余额已为负,直接终止回测: $currentBalance")
|
||||
break
|
||||
}
|
||||
if (currentBalance < BigDecimal.ONE && positions.isEmpty()) {
|
||||
logger.info("余额不足且无持仓,停止回测: $currentBalance")
|
||||
if (currentBalance <= BigDecimal.ONE) {
|
||||
logger.info(
|
||||
if (currentBalance < BigDecimal.ZERO) "余额已为负,直接终止回测: $currentBalance"
|
||||
else "余额<=1,停止回测: $currentBalance"
|
||||
)
|
||||
terminateBacktest = true
|
||||
break
|
||||
}
|
||||
|
||||
@@ -262,21 +264,80 @@ class BacktestExecutionService(
|
||||
|
||||
// 5.7 处理买卖逻辑
|
||||
if (leaderTrade.side == "BUY") {
|
||||
// 买入逻辑
|
||||
val quantity = finalFollowAmount.divide(leaderTrade.price, 8, java.math.RoundingMode.DOWN)
|
||||
val totalCost = finalFollowAmount
|
||||
// 余额不足时按最大可用余额交易,仍须满足最小订单金额
|
||||
val actualBuyAmount = if (currentBalance < finalFollowAmount) {
|
||||
logger.debug("余额不足,按最大余额买入: balance=$currentBalance, 原需=$finalFollowAmount, marketId=${leaderTrade.marketId}")
|
||||
currentBalance
|
||||
} else {
|
||||
finalFollowAmount
|
||||
}
|
||||
if (actualBuyAmount < task.minOrderSize) {
|
||||
logger.debug("可用金额低于最小订单限制跳过: actual=$actualBuyAmount, minOrderSize=${task.minOrderSize}")
|
||||
continue
|
||||
}
|
||||
val quantity = actualBuyAmount.divide(leaderTrade.price, 8, java.math.RoundingMode.DOWN)
|
||||
if (quantity <= BigDecimal.ZERO) {
|
||||
logger.debug("计算数量为0跳过: actualBuyAmount=$actualBuyAmount, price=${leaderTrade.price}")
|
||||
continue
|
||||
}
|
||||
val totalCost = actualBuyAmount
|
||||
|
||||
// 更新余额和持仓
|
||||
// 5.6.3 检查最大仓位限制(如果配置了)
|
||||
if (task.maxPositionValue != null) {
|
||||
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
|
||||
val currentPosition = positions[positionKey]
|
||||
val currentPositionValue = if (currentPosition != null) {
|
||||
currentPosition.quantity.multiply(currentPosition.avgPrice)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
val totalValueAfterOrder = currentPositionValue.add(actualBuyAmount)
|
||||
|
||||
if (totalValueAfterOrder.gt(task.maxPositionValue)) {
|
||||
val currentPositionValueStr = currentPositionValue.stripTrailingZeros().toPlainString()
|
||||
val totalValueStr = totalValueAfterOrder.stripTrailingZeros().toPlainString()
|
||||
val maxValueStr = task.maxPositionValue.stripTrailingZeros().toPlainString()
|
||||
logger.info("超过最大仓位金额限制: 市场=${leaderTrade.marketId}, 方向=${leaderTrade.outcomeIndex}, 当前仓位=${currentPositionValueStr} USDC, 买入金额=${actualBuyAmount} USDC, 总计=${totalValueStr} USDC > 最大限制=${maxValueStr} USDC")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 更新余额和持仓(同市场同 outcome 多次买入合并:数量相加、加权均价、leaderBuyQuantity 相加)
|
||||
currentBalance -= totalCost
|
||||
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
|
||||
positions[positionKey] = Position(
|
||||
marketId = leaderTrade.marketId,
|
||||
outcome = leaderTrade.outcome ?: "",
|
||||
outcomeIndex = leaderTrade.outcomeIndex,
|
||||
quantity = quantity,
|
||||
avgPrice = leaderTrade.price.toSafeBigDecimal(),
|
||||
leaderBuyQuantity = leaderTrade.size.toSafeBigDecimal()
|
||||
)
|
||||
val price = leaderTrade.price.toSafeBigDecimal()
|
||||
val leaderSize = leaderTrade.size.toSafeBigDecimal()
|
||||
val existing = positions[positionKey]
|
||||
positions[positionKey] = if (existing != null) {
|
||||
val newQuantity = existing.quantity.add(quantity)
|
||||
val newAvgPrice = if (newQuantity > BigDecimal.ZERO) {
|
||||
existing.quantity.multiply(existing.avgPrice).add(quantity.multiply(price))
|
||||
.divide(newQuantity, 8, java.math.RoundingMode.HALF_UP)
|
||||
} else {
|
||||
price
|
||||
}
|
||||
val newLeaderBuyQuantity = (existing.leaderBuyQuantity ?: BigDecimal.ZERO).add(leaderSize)
|
||||
Position(
|
||||
marketId = leaderTrade.marketId,
|
||||
outcome = leaderTrade.outcome ?: "",
|
||||
outcomeIndex = leaderTrade.outcomeIndex,
|
||||
quantity = newQuantity,
|
||||
avgPrice = newAvgPrice,
|
||||
leaderBuyQuantity = newLeaderBuyQuantity,
|
||||
marketEndDate = existing.marketEndDate
|
||||
)
|
||||
} else {
|
||||
val market = marketService.getMarket(leaderTrade.marketId)
|
||||
Position(
|
||||
marketId = leaderTrade.marketId,
|
||||
outcome = leaderTrade.outcome ?: "",
|
||||
outcomeIndex = leaderTrade.outcomeIndex,
|
||||
quantity = quantity,
|
||||
avgPrice = price,
|
||||
leaderBuyQuantity = leaderSize,
|
||||
marketEndDate = market?.endDate
|
||||
)
|
||||
}
|
||||
|
||||
// 记录交易到当前页列表
|
||||
currentPageTrades.add(BacktestTrade(
|
||||
@@ -289,7 +350,7 @@ class BacktestExecutionService(
|
||||
outcomeIndex = leaderTrade.outcomeIndex,
|
||||
quantity = quantity,
|
||||
price = leaderTrade.price.toSafeBigDecimal(),
|
||||
amount = finalFollowAmount,
|
||||
amount = actualBuyAmount,
|
||||
fee = BigDecimal.ZERO,
|
||||
profitLoss = null,
|
||||
balanceAfter = currentBalance,
|
||||
@@ -384,14 +445,11 @@ class BacktestExecutionService(
|
||||
}
|
||||
}
|
||||
|
||||
// 保存当前页的所有交易(每页处理完成后保存,避免重复插入)
|
||||
// 保存本批交易
|
||||
if (currentPageTrades.isNotEmpty()) {
|
||||
logger.info("保存第 $currentPage 页的交易数据,共 ${currentPageTrades.size} 笔")
|
||||
|
||||
// 批量保存当前页的交易
|
||||
logger.info("保存本批交易,共 ${currentPageTrades.size} 笔")
|
||||
backtestTradeRepository.saveAll(currentPageTrades)
|
||||
|
||||
// 更新当前页的最后处理信息
|
||||
val lastTradeInPage = currentPageTrades.lastOrNull()
|
||||
if (lastTradeInPage != null && lastProcessedIndexInPage != null) {
|
||||
task.lastProcessedTradeTime = lastTradeInPage.tradeTime
|
||||
@@ -399,28 +457,34 @@ class BacktestExecutionService(
|
||||
task.processedTradeCount = lastProcessedIndexInPage + 1
|
||||
task.finalBalance = currentBalance
|
||||
backtestTaskRepository.save(task)
|
||||
|
||||
logger.info("第 $currentPage 页处理完成,更新索引: ${task.lastProcessedTradeIndex}, 总处理数: ${task.processedTradeCount}")
|
||||
logger.info("本批处理完成,lastProcessedTradeIndex=${task.lastProcessedTradeIndex}, 总处理数=${task.processedTradeCount}")
|
||||
}
|
||||
} else {
|
||||
logger.info("第 $currentPage 页没有交易需要保存")
|
||||
logger.info("本批没有交易需要保存")
|
||||
}
|
||||
|
||||
// 将当前页交易添加到全局列表(用于最终统计)
|
||||
trades.addAll(currentPageTrades)
|
||||
|
||||
// 准备处理下一页
|
||||
currentPage++
|
||||
if (batch.nextCursorSeconds == null) {
|
||||
logger.info("本批不足 $backtestBatchLimit 条,已是最后一页")
|
||||
break
|
||||
}
|
||||
cursorSeconds = batch.nextCursorSeconds!!
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取或处理第 $currentPage 页数据失败: ${e.message}", e)
|
||||
logger.error("获取或处理本批数据失败: ${e.message}", e)
|
||||
// 重试失败,标记任务为 FAILED
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 处理回测结束时仍未到期的持仓
|
||||
currentBalance = settleRemainingPositions(task, positions, currentBalance, trades, endTime)
|
||||
val remainingSettlements = mutableListOf<BacktestTrade>()
|
||||
currentBalance = settleRemainingPositions(task, positions, currentBalance, trades, endTime, remainingSettlements)
|
||||
if (remainingSettlements.isNotEmpty()) {
|
||||
backtestTradeRepository.saveAll(remainingSettlements)
|
||||
logger.info("回测结束结算剩余持仓,持久化 ${remainingSettlements.size} 笔 SETTLEMENT(CLOSED)")
|
||||
}
|
||||
|
||||
// 7. 计算最终统计数据
|
||||
val statistics = calculateStatistics(trades)
|
||||
@@ -475,18 +539,25 @@ class BacktestExecutionService(
|
||||
|
||||
/**
|
||||
* 结算已到期的市场
|
||||
* @param batchTradesToSave 本批要持久化的交易列表,到期结算(赎回/输)会追加到此列表并随本批一起落库
|
||||
*/
|
||||
private suspend fun settleExpiredPositions(
|
||||
task: BacktestTask,
|
||||
positions: MutableMap<String, Position>,
|
||||
currentBalance: BigDecimal,
|
||||
trades: MutableList<BacktestTrade>,
|
||||
currentTime: Long
|
||||
currentTime: Long,
|
||||
batchTradesToSave: MutableList<BacktestTrade>
|
||||
): BigDecimal {
|
||||
var balance = currentBalance
|
||||
|
||||
for ((positionKey, position) in positions.toList()) {
|
||||
try {
|
||||
// 仅当市场已到期(结束时间 <= 当前回测时间)时才结算,避免未到期持仓被误结算
|
||||
if (position.marketEndDate == null || position.marketEndDate!! > currentTime) {
|
||||
logger.debug("持仓未到期跳过结算: marketId=${position.marketId}, endDate=${position.marketEndDate}, currentTime=$currentTime")
|
||||
continue
|
||||
}
|
||||
// 获取市场当前价格
|
||||
val marketPrice = marketPriceService.getCurrentMarketPrice(
|
||||
position.marketId,
|
||||
@@ -507,12 +578,12 @@ class BacktestExecutionService(
|
||||
|
||||
balance += settlementValue
|
||||
|
||||
// 记录结算交易
|
||||
trades.add(BacktestTrade(
|
||||
val marketTitle = marketService.getMarket(position.marketId)?.title ?: ""
|
||||
val settlementTrade = BacktestTrade(
|
||||
backtestTaskId = task.id!!,
|
||||
tradeTime = currentTime,
|
||||
marketId = position.marketId,
|
||||
marketTitle = "",
|
||||
marketTitle = marketTitle,
|
||||
side = "SETTLEMENT",
|
||||
outcome = when {
|
||||
settlementPrice == BigDecimal.ONE -> "WIN"
|
||||
@@ -527,7 +598,9 @@ class BacktestExecutionService(
|
||||
profitLoss = profitLoss,
|
||||
balanceAfter = balance,
|
||||
leaderTradeId = null
|
||||
))
|
||||
)
|
||||
trades.add(settlementTrade)
|
||||
batchTradesToSave.add(settlementTrade)
|
||||
|
||||
// 移除已结算的持仓
|
||||
positions.remove(positionKey)
|
||||
@@ -540,14 +613,16 @@ class BacktestExecutionService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算未到期持仓
|
||||
* 结算未到期持仓(回测结束时剩余持仓按均价平仓)
|
||||
* @param settlementsToSave 本批结算记录会追加到此列表,调用方需落库
|
||||
*/
|
||||
private suspend fun settleRemainingPositions(
|
||||
task: BacktestTask,
|
||||
positions: MutableMap<String, Position>,
|
||||
currentBalance: BigDecimal,
|
||||
trades: MutableList<BacktestTrade>,
|
||||
currentTime: Long
|
||||
currentTime: Long,
|
||||
settlementsToSave: MutableList<BacktestTrade>
|
||||
): BigDecimal {
|
||||
var balance = currentBalance
|
||||
|
||||
@@ -561,12 +636,12 @@ class BacktestExecutionService(
|
||||
|
||||
balance += settlementValue
|
||||
|
||||
// 记录平仓交易
|
||||
trades.add(BacktestTrade(
|
||||
val marketTitle = marketService.getMarket(position.marketId)?.title ?: ""
|
||||
val closedTrade = BacktestTrade(
|
||||
backtestTaskId = task.id!!,
|
||||
tradeTime = currentTime,
|
||||
marketId = position.marketId,
|
||||
marketTitle = "",
|
||||
marketTitle = marketTitle,
|
||||
side = "SETTLEMENT",
|
||||
outcome = "CLOSED",
|
||||
outcomeIndex = position.outcomeIndex,
|
||||
@@ -577,7 +652,9 @@ class BacktestExecutionService(
|
||||
profitLoss = profitLoss,
|
||||
balanceAfter = balance,
|
||||
leaderTradeId = null
|
||||
))
|
||||
)
|
||||
trades.add(closedTrade)
|
||||
settlementsToSave.add(closedTrade)
|
||||
}
|
||||
|
||||
positions.clear()
|
||||
|
||||
+3
-29
@@ -32,8 +32,6 @@ class BacktestPollingService(
|
||||
@Scheduled(fixedDelay = 10000) // 10 秒
|
||||
fun pollPendingTasks() {
|
||||
try {
|
||||
logger.debug("开始轮询待执行的回测任务")
|
||||
|
||||
// 1. 检查是否有长时间处于 RUNNING 状态的任务(可能是应用重启导致的)
|
||||
val runningTasks = backtestTaskRepository.findByStatus("RUNNING")
|
||||
if (runningTasks.isNotEmpty()) {
|
||||
@@ -72,7 +70,6 @@ class BacktestPollingService(
|
||||
.sortedBy { it.createdAt }
|
||||
|
||||
if (pendingTasks.isEmpty()) {
|
||||
logger.debug("没有待执行的回测任务")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -91,32 +88,9 @@ class BacktestPollingService(
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
// 支持恢复:如果有恢复点,计算从哪一页开始
|
||||
val pageSize = 100
|
||||
val page = if (currentTask.lastProcessedTradeIndex != null) {
|
||||
// 从第几页开始(页码从 0 开始)
|
||||
// 例如:已处理了99笔,lastProcessedTradeIndex=99,应从第1页开始(offset=100)
|
||||
val lastProcessedIndex = currentTask.lastProcessedTradeIndex!!
|
||||
// 计算已处理的页码(从 0 开始)
|
||||
val processedPage = lastProcessedIndex / pageSize
|
||||
|
||||
// 特殊情况:如果lastProcessedTradeIndex刚好是100的倍数减1(比如99,199,299...)
|
||||
// 说明该页已经完全处理,应该从下一页开始
|
||||
val nextPage = if (lastProcessedIndex % pageSize == pageSize - 1) {
|
||||
processedPage + 1
|
||||
} else {
|
||||
processedPage
|
||||
}
|
||||
|
||||
logger.info("恢复任务:已处理索引=$lastProcessedIndex, 计算页码=$nextPage, size=$pageSize")
|
||||
nextPage
|
||||
} else {
|
||||
logger.info("新任务:从第0页开始")
|
||||
0 // 从第0页开始(offset=0)
|
||||
}
|
||||
|
||||
logger.info("执行回测任务: taskId=${currentTask.id}, page=$page, size=$pageSize")
|
||||
executionService.executeBacktest(currentTask, page = page, size = pageSize)
|
||||
// 使用 start 游标分页,恢复时由 lastProcessedTradeTime 决定从何时开始拉取
|
||||
logger.info("执行回测任务: taskId=${currentTask.id}(游标分页,limit=500)")
|
||||
executionService.executeBacktest(currentTask, page = 0, size = 500)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("回测任务执行失败: taskId=${taskToExecute.id}", e)
|
||||
|
||||
+55
-2
@@ -81,7 +81,10 @@ class BacktestService(
|
||||
request.keywords.toJson()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal()
|
||||
)
|
||||
|
||||
backtestTaskRepository.save(task)
|
||||
@@ -190,7 +193,10 @@ class BacktestService(
|
||||
task.keywords.fromJson<List<String>>()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
},
|
||||
maxPositionValue = task.maxPositionValue?.toPlainString(),
|
||||
minPrice = task.minPrice?.toPlainString(),
|
||||
maxPrice = task.maxPrice?.toPlainString()
|
||||
)
|
||||
|
||||
val statistics = BacktestStatisticsDto(
|
||||
@@ -343,6 +349,53 @@ class BacktestService(
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前配置重新测试:基于已完成的回测任务创建一份相同配置的新任务(名称可修改)
|
||||
*/
|
||||
@Transactional
|
||||
fun rerunBacktestTask(request: BacktestRerunRequest): Result<BacktestTaskDto> {
|
||||
return try {
|
||||
val source = backtestTaskRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
|
||||
|
||||
if (source.status != "COMPLETED") {
|
||||
return Result.failure(IllegalStateException("仅支持对已完成的回测任务重新测试"))
|
||||
}
|
||||
|
||||
val newTaskName = request.taskName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: "${source.taskName} (副本)"
|
||||
|
||||
val newTask = BacktestTask(
|
||||
taskName = newTaskName,
|
||||
leaderId = source.leaderId,
|
||||
initialBalance = source.initialBalance,
|
||||
backtestDays = source.backtestDays,
|
||||
startTime = source.startTime,
|
||||
status = "PENDING",
|
||||
copyMode = source.copyMode,
|
||||
copyRatio = source.copyRatio,
|
||||
fixedAmount = source.fixedAmount,
|
||||
maxOrderSize = source.maxOrderSize,
|
||||
minOrderSize = source.minOrderSize,
|
||||
maxDailyLoss = source.maxDailyLoss,
|
||||
maxDailyOrders = source.maxDailyOrders,
|
||||
supportSell = source.supportSell,
|
||||
keywordFilterMode = source.keywordFilterMode,
|
||||
keywords = source.keywords,
|
||||
maxPositionValue = source.maxPositionValue,
|
||||
minPrice = source.minPrice,
|
||||
maxPrice = source.maxPrice
|
||||
)
|
||||
|
||||
backtestTaskRepository.save(newTask)
|
||||
val leader = leaderRepository.findById(newTask.leaderId).orElse(null)
|
||||
Result.success(newTask.toDto(leader))
|
||||
} catch (e: Exception) {
|
||||
logger.error("按配置重新测试失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+14
-17
@@ -10,6 +10,7 @@ import com.wrbug.polymarketbot.api.ValueResponse
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.dto.PositionDto
|
||||
import com.wrbug.polymarketbot.dto.WalletBalanceResponse
|
||||
import com.wrbug.polymarketbot.enums.WalletType
|
||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
@@ -93,13 +94,13 @@ class BlockchainService(
|
||||
* 2. Safe Proxy(MetaMask 钱包用户)- 通过合约调用获取地址
|
||||
*
|
||||
* @param walletAddress 用户的钱包地址(EOA)
|
||||
* @param walletType 钱包类型:"magic"(默认)或 "safe"
|
||||
* @param walletType 钱包类型:MAGIC(默认)或 SAFE
|
||||
* @return 代理钱包地址
|
||||
*/
|
||||
suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result<String> {
|
||||
suspend fun getProxyAddress(walletAddress: String, walletType: WalletType = WalletType.MAGIC): Result<String> {
|
||||
return try {
|
||||
when (walletType.lowercase()) {
|
||||
"safe" -> {
|
||||
when (walletType) {
|
||||
WalletType.SAFE -> {
|
||||
// Safe Proxy(MetaMask 用户)
|
||||
val safeProxyResult = getSafeProxyAddress(walletAddress)
|
||||
if (safeProxyResult.isSuccess) {
|
||||
@@ -110,7 +111,7 @@ class BlockchainService(
|
||||
Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败"))
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
WalletType.MAGIC -> {
|
||||
// Magic Proxy(邮箱/OAuth 登录用户)- 默认
|
||||
val magicProxyAddress = calculateMagicProxyAddress(walletAddress)
|
||||
logger.debug("使用 Magic Proxy 地址: $magicProxyAddress")
|
||||
@@ -580,39 +581,35 @@ class BlockchainService(
|
||||
|
||||
/**
|
||||
* 赎回仓位
|
||||
* 通过代理钱包的 execTransaction 调用 ConditionalTokens 合约的 redeemPositions 函数
|
||||
*
|
||||
* 使用 RelayClientService 实现,完全参考 TypeScript 项目的实现方式
|
||||
*
|
||||
* Safe 账户通过代理 execTransaction 调用,Magic 账户通过 Builder Relayer PROXY(Gasless)执行
|
||||
*
|
||||
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
|
||||
* @param proxyAddress 代理地址(Gnosis Safe 代理钱包地址)
|
||||
* @param proxyAddress 代理地址(Safe 或 Magic 代理钱包地址)
|
||||
* @param conditionId 市场条件ID(bytes32,必须是 0x 开头的 66 位十六进制字符串)
|
||||
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex,例如 [1] 表示 outcome 0,[2] 表示 outcome 1)
|
||||
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex)
|
||||
* @param walletType 钱包类型:MAGIC 或 SAFE,用于选择执行路径
|
||||
* @return 交易哈希
|
||||
*/
|
||||
suspend fun redeemPositions(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
conditionId: String,
|
||||
indexSets: List<BigInteger>
|
||||
indexSets: List<BigInteger>,
|
||||
walletType: WalletType = WalletType.SAFE
|
||||
): Result<String> {
|
||||
return try {
|
||||
// 验证参数
|
||||
if (indexSets.isEmpty()) {
|
||||
return Result.failure(IllegalArgumentException("indexSets 不能为空"))
|
||||
}
|
||||
|
||||
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
|
||||
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
|
||||
}
|
||||
|
||||
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
||||
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
||||
}
|
||||
|
||||
// 使用 RelayClientService 创建赎回交易并执行
|
||||
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets)
|
||||
relayClientService.execute(privateKey, proxyAddress, redeemTx)
|
||||
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
|
||||
} catch (e: Exception) {
|
||||
logger.error("赎回仓位失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
|
||||
+9
-4
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.service.copytrading.leaders
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
@@ -20,9 +21,10 @@ class LeaderService(
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val backtestTaskRepository: BacktestTaskRepository,
|
||||
private val blockchainService: BlockchainService
|
||||
) {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(LeaderService::class.java)
|
||||
|
||||
/**
|
||||
@@ -157,7 +159,8 @@ class LeaderService(
|
||||
|
||||
val leaderDtos = leaders.map { leader ->
|
||||
val copyTradingCount = copyTradingRepository.countByLeaderId(leader.id!!)
|
||||
toDto(leader, copyTradingCount)
|
||||
val backtestCount = backtestTaskRepository.findByLeaderId(leader.id).size.toLong()
|
||||
toDto(leader, copyTradingCount, backtestCount)
|
||||
}
|
||||
|
||||
Result.success(
|
||||
@@ -181,7 +184,8 @@ class LeaderService(
|
||||
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
|
||||
|
||||
val copyTradingCount = copyTradingRepository.countByLeaderId(leaderId)
|
||||
Result.success(toDto(leader, copyTradingCount))
|
||||
val backtestCount = backtestTaskRepository.findByLeaderId(leaderId).size.toLong()
|
||||
Result.success(toDto(leader, copyTradingCount, backtestCount))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询 Leader 详情失败", e)
|
||||
Result.failure(e)
|
||||
@@ -225,7 +229,7 @@ class LeaderService(
|
||||
/**
|
||||
* 转换为 DTO
|
||||
*/
|
||||
private fun toDto(leader: Leader, copyTradingCount: Long = 0): LeaderDto {
|
||||
private fun toDto(leader: Leader, copyTradingCount: Long = 0, backtestCount: Long = 0): LeaderDto {
|
||||
return LeaderDto(
|
||||
id = leader.id!!,
|
||||
leaderAddress = leader.leaderAddress,
|
||||
@@ -234,6 +238,7 @@ class LeaderService(
|
||||
remark = leader.remark,
|
||||
website = leader.website,
|
||||
copyTradingCount = copyTradingCount,
|
||||
backtestCount = backtestCount,
|
||||
createdAt = leader.createdAt,
|
||||
updatedAt = leader.updatedAt
|
||||
)
|
||||
|
||||
+12
-2
@@ -19,9 +19,19 @@ import java.math.RoundingMode
|
||||
*/
|
||||
@Service
|
||||
class OrderSigningService {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderSigningService::class.java)
|
||||
|
||||
|
||||
/**
|
||||
* 根据钱包类型返回 CLOB 订单签名类型
|
||||
* @param walletType Magic=邮箱/社交登录, Safe=Web3 钱包
|
||||
* @return 1=POLY_PROXY(Magic), 2=POLY_GNOSIS_SAFE(Safe), 默认 2
|
||||
*/
|
||||
fun getSignatureTypeForWalletType(walletType: String?): Int {
|
||||
val walletTypeEnum = com.wrbug.polymarketbot.enums.WalletType.fromStringOrDefault(walletType, com.wrbug.polymarketbot.enums.WalletType.SAFE)
|
||||
return if (walletTypeEnum == com.wrbug.polymarketbot.enums.WalletType.MAGIC) 1 else 2
|
||||
}
|
||||
|
||||
// Polygon 主网合约地址
|
||||
private val EXCHANGE_CONTRACT = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
|
||||
private val CHAIN_ID = 137L
|
||||
|
||||
+10
-6
@@ -570,7 +570,8 @@ open class CopyOrderTrackingService(
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id!!,
|
||||
tradeId = trade.id,
|
||||
feeRateBps = feeRateBps
|
||||
feeRateBps = feeRateBps,
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
)
|
||||
|
||||
// 处理订单创建失败
|
||||
@@ -994,7 +995,7 @@ open class CopyOrderTrackingService(
|
||||
"0"
|
||||
}
|
||||
|
||||
// 9. 创建并签名卖出订单
|
||||
// 9. 创建并签名卖出订单(按账户钱包类型使用对应 signatureType)
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedPrivateKey,
|
||||
@@ -1003,7 +1004,7 @@ open class CopyOrderTrackingService(
|
||||
side = "SELL",
|
||||
price = sellPrice.toString(),
|
||||
size = totalMatched.toString(),
|
||||
signatureType = 2, // Browser Wallet
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0"
|
||||
@@ -1044,7 +1045,8 @@ open class CopyOrderTrackingService(
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id,
|
||||
tradeId = leaderSellTrade.id,
|
||||
feeRateBps = feeRateBps
|
||||
feeRateBps = feeRateBps,
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
)
|
||||
|
||||
if (createOrderResult.isFailure) {
|
||||
@@ -1137,6 +1139,7 @@ open class CopyOrderTrackingService(
|
||||
* @param copyTradingId 跟单配置ID(用于日志)
|
||||
* @param tradeId Leader 交易ID(用于日志)
|
||||
* @param feeRateBps 费率基点(从API动态获取)
|
||||
* @param signatureType 签名类型(1=Magic, 2=Safe)
|
||||
* @return 成功返回订单ID,失败返回异常
|
||||
*/
|
||||
private suspend fun createOrderWithRetry(
|
||||
@@ -1150,7 +1153,8 @@ open class CopyOrderTrackingService(
|
||||
owner: String,
|
||||
copyTradingId: Long,
|
||||
tradeId: String,
|
||||
feeRateBps: String
|
||||
feeRateBps: String,
|
||||
signatureType: Int
|
||||
): Result<String> {
|
||||
var lastError: Exception? = null
|
||||
|
||||
@@ -1165,7 +1169,7 @@ open class CopyOrderTrackingService(
|
||||
side = side,
|
||||
price = price,
|
||||
size = size,
|
||||
signatureType = 2, // Browser Wallet
|
||||
signatureType = signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0"
|
||||
|
||||
+259
-12
@@ -4,6 +4,7 @@ import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.enums.WalletType
|
||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
@@ -40,6 +41,15 @@ class RelayClientService(
|
||||
// 空集合ID
|
||||
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
// Polygon PROXY(Magic)合约地址,参考 builder-relayer-client config
|
||||
private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
|
||||
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
|
||||
private val defaultProxyGasLimit = "10000000"
|
||||
|
||||
// Builder Relayer API 交易类型常量
|
||||
private val RELAYER_TYPE_PROXY = "PROXY"
|
||||
private val RELAYER_TYPE_SAFE = "SAFE"
|
||||
|
||||
private val polygonRpcApi: EthereumRpcApi by lazy {
|
||||
val rpcUrl = rpcNodeService.getHttpUrl()
|
||||
retrofitFactory.createEthereumRpcApi(rpcUrl)
|
||||
@@ -201,33 +211,45 @@ class RelayClientService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Safe 交易(通过 Proxy.execTransaction)
|
||||
* 执行代理交易(Safe 或 Magic PROXY)
|
||||
* 参考 TypeScript: RelayClient.execute()
|
||||
*
|
||||
* 优先使用 Builder Relayer(Gasless),如果未配置则回退到手动发送交易
|
||||
*
|
||||
* @param privateKey 私钥
|
||||
* @param proxyAddress 代理钱包地址
|
||||
* @param safeTx Safe 交易对象
|
||||
* @param safeTx 交易对象(to/data/value)
|
||||
* @param walletType 钱包类型:MAGIC 使用 PROXY Gasless,SAFE 使用 Safe 流程
|
||||
* @return 交易哈希
|
||||
*/
|
||||
suspend fun execute(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
safeTx: SafeTransaction
|
||||
safeTx: SafeTransaction,
|
||||
walletType: WalletType = WalletType.SAFE
|
||||
): Result<String> {
|
||||
return try {
|
||||
// 验证参数
|
||||
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
||||
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
||||
}
|
||||
|
||||
// 检查 Builder API Key 是否已配置
|
||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||
val builderSecret = systemConfigService.getBuilderSecret()
|
||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||
|
||||
// 优先使用 Builder Relayer(Gasless)
|
||||
if (walletType == WalletType.MAGIC) {
|
||||
if (!isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||
return Result.failure(IllegalStateException("Magic 账户赎回必须配置 Builder API Key(Gasless)"))
|
||||
}
|
||||
logger.info("使用 Builder Relayer PROXY 执行 Magic 赎回")
|
||||
return executeViaBuilderRelayerProxy(
|
||||
privateKey,
|
||||
proxyAddress,
|
||||
safeTx,
|
||||
builderApiKey!!,
|
||||
builderSecret!!,
|
||||
builderPassphrase!!
|
||||
)
|
||||
}
|
||||
|
||||
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||
logger.info("使用 Builder Relayer 执行 Gasless 交易")
|
||||
return executeViaBuilderRelayer(
|
||||
@@ -240,15 +262,240 @@ class RelayClientService(
|
||||
)
|
||||
}
|
||||
|
||||
// 回退到手动发送交易(需要用户支付 gas)
|
||||
logger.info("Builder Relayer 未配置,使用手动发送交易(需要用户支付 gas)")
|
||||
return executeManually(privateKey, proxyAddress, safeTx)
|
||||
} catch (e: Exception) {
|
||||
logger.error("执行 Safe 交易失败: ${e.message}", e)
|
||||
logger.error("执行交易失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Builder Relayer 执行 PROXY(Magic)交易(Gasless)
|
||||
* 参考: builder-relayer-client client.ts executeProxyTransactions, builder/proxy.ts
|
||||
*/
|
||||
private suspend fun executeViaBuilderRelayerProxy(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
safeTx: SafeTransaction,
|
||||
builderApiKey: String,
|
||||
builderSecret: String,
|
||||
builderPassphrase: String
|
||||
): Result<String> {
|
||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
apiKey = builderApiKey,
|
||||
secret = builderSecret,
|
||||
passphrase = builderPassphrase
|
||||
)
|
||||
|
||||
val cleanPrivateKey = privateKey.removePrefix("0x")
|
||||
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
|
||||
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
|
||||
val fromAddress = credentials.address
|
||||
|
||||
val relayPayloadResponse = relayerApi.getRelayPayload(fromAddress, RELAYER_TYPE_PROXY)
|
||||
if (!relayPayloadResponse.isSuccessful || relayPayloadResponse.body() == null) {
|
||||
val errorBody = relayPayloadResponse.errorBody()?.string() ?: "未知错误"
|
||||
logger.error("获取 Relay Payload 失败: code=${relayPayloadResponse.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("获取 Relay Payload 失败: ${relayPayloadResponse.code()} - $errorBody"))
|
||||
}
|
||||
val relayPayload = relayPayloadResponse.body()!!
|
||||
val relayAddress = relayPayload.address
|
||||
val nonce = relayPayload.nonce
|
||||
|
||||
val proxyCallData = encodeProxyTransactionData(safeTx)
|
||||
|
||||
// 估算 gas limit(参考 builder-relayer-client builder/proxy.ts getGasLimit)
|
||||
val gasLimit = try {
|
||||
estimateProxyGasLimit(fromAddress, proxyFactoryAddress, proxyCallData)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("估算 PROXY gas limit 失败,使用默认值: ${e.message}", e)
|
||||
defaultProxyGasLimit
|
||||
}
|
||||
|
||||
val structHash = createProxyStructHash(
|
||||
from = fromAddress,
|
||||
to = proxyFactoryAddress,
|
||||
data = proxyCallData,
|
||||
txFee = "0",
|
||||
gasPrice = "0",
|
||||
gasLimit = gasLimit,
|
||||
nonce = nonce,
|
||||
relayHubAddress = relayHubAddress,
|
||||
relayAddress = relayAddress
|
||||
)
|
||||
|
||||
val prefix = "\u0019Ethereum Signed Message:\n32".toByteArray(Charsets.UTF_8)
|
||||
val messageWithPrefix = ByteArray(prefix.size + structHash.size)
|
||||
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
|
||||
System.arraycopy(structHash, 0, messageWithPrefix, prefix.size, structHash.size)
|
||||
|
||||
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
|
||||
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
|
||||
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
||||
keccak256.doFinal(hashWithPrefix, 0)
|
||||
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
val signature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
||||
val sigHex = "0x" + org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0') +
|
||||
org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0') +
|
||||
String.format("%02x", (signature.v as ByteArray).getOrElse(0) { 0 }.toInt() and 0xff)
|
||||
|
||||
val request = BuilderRelayerApi.TransactionRequest(
|
||||
type = RELAYER_TYPE_PROXY,
|
||||
from = fromAddress,
|
||||
to = proxyFactoryAddress,
|
||||
proxyWallet = proxyAddress,
|
||||
data = proxyCallData,
|
||||
nonce = nonce,
|
||||
signature = sigHex,
|
||||
signatureParams = BuilderRelayerApi.SignatureParams(
|
||||
gasPrice = "0",
|
||||
gasLimit = gasLimit,
|
||||
relayerFee = "0",
|
||||
relayHub = relayHubAddress,
|
||||
relay = relayAddress
|
||||
),
|
||||
metadata = "Redeem positions via Builder Relayer PROXY"
|
||||
)
|
||||
|
||||
val response = relayerApi.submitTransaction(request)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||
logger.error("Builder Relayer PROXY API 调用失败: code=${response.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("Builder Relayer PROXY 调用失败: ${response.code()} - $errorBody"))
|
||||
}
|
||||
|
||||
val relayerResponse = response.body()!!
|
||||
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
|
||||
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
|
||||
logger.info("Builder Relayer PROXY 执行成功: transactionID=${relayerResponse.transactionID}, txHash=$txHash")
|
||||
return Result.success(txHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码 ProxyFactory.proxy(calls) 调用数据
|
||||
* 参考: builder-relayer-client encode/proxy.ts, abis proxyFactory proxy((uint8,address,uint256,bytes)[])
|
||||
*
|
||||
* ABI 编码规则:当 tuple 数组中的 tuple 包含动态类型(bytes)时,需要先存储 tuple offset
|
||||
* 结构:
|
||||
* - selector (4 bytes)
|
||||
* - array offset (32 bytes) = 32
|
||||
* - array length (32 bytes) = 1
|
||||
* - tuple[0] offset (32 bytes) = 32 (指向 tuple 数据开始,从 array length 之后计算)
|
||||
* - tuple[0] 数据:
|
||||
* - typeCode (32 bytes) = 1
|
||||
* - to (32 bytes)
|
||||
* - value (32 bytes) = 0
|
||||
* - data offset (32 bytes) = 128 (从 tuple 数据开始计算)
|
||||
* - data length (32 bytes)
|
||||
* - data (padded to 32-byte boundary)
|
||||
*/
|
||||
private fun encodeProxyTransactionData(safeTx: SafeTransaction): String {
|
||||
val selector = EthereumUtils.getFunctionSelector("proxy((uint8,address,uint256,bytes)[])")
|
||||
val callData = safeTx.data.removePrefix("0x")
|
||||
val dataLen = callData.length / 2
|
||||
val dataLenPadded = (dataLen + 31) / 32 * 32 * 2
|
||||
val dataPadded = callData.padEnd(dataLenPadded, '0')
|
||||
|
||||
// ABI 编码:tuple 数组,tuple 包含动态类型 bytes
|
||||
// 1. array offset: 32 (指向 array length)
|
||||
val arrayOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
|
||||
// 2. array length: 1
|
||||
val arrayLength = EthereumUtils.encodeUint256(BigInteger.ONE)
|
||||
// 3. tuple[0] offset: 32 (指向 tuple 数据开始,从 array length 之后计算)
|
||||
val tupleOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
|
||||
// 4. tuple[0] 数据:
|
||||
// - typeCode: 1
|
||||
val typeCode = EthereumUtils.encodeUint256(BigInteger.ONE)
|
||||
// - to: address
|
||||
val toEncoded = EthereumUtils.encodeAddress(safeTx.to)
|
||||
// - value: 0
|
||||
val valueEncoded = EthereumUtils.encodeUint256(BigInteger.ZERO)
|
||||
// - data offset: 128 (从 tuple 数据开始计算,typeCode+to+value = 3*32 = 96,加上 offset 字段 = 128)
|
||||
val dataOffsetInTuple = BigInteger.valueOf(128)
|
||||
val dataOffsetEncoded = EthereumUtils.encodeUint256(dataOffsetInTuple)
|
||||
// - data length
|
||||
val dataLengthEncoded = EthereumUtils.encodeUint256(BigInteger.valueOf(dataLen.toLong()))
|
||||
// - data (padded)
|
||||
|
||||
return "0x" + selector.removePrefix("0x") + arrayOffset + arrayLength +
|
||||
tupleOffset + typeCode + toEncoded + valueEncoded + dataOffsetEncoded +
|
||||
dataLengthEncoded + dataPadded
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算 PROXY 交易的 gas limit
|
||||
* 参考: builder-relayer-client builder/proxy.ts getGasLimit
|
||||
*/
|
||||
private suspend fun estimateProxyGasLimit(
|
||||
from: String,
|
||||
to: String,
|
||||
data: String
|
||||
): String {
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_estimateGas",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"from" to from,
|
||||
"to" to to,
|
||||
"data" to data
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
throw Exception("eth_estimateGas 调用失败: ${response.code()} ${response.message()}")
|
||||
}
|
||||
|
||||
val rpcResponse = response.body()!!
|
||||
if (rpcResponse.error != null) {
|
||||
throw Exception("eth_estimateGas 返回错误: ${rpcResponse.error.message}")
|
||||
}
|
||||
|
||||
val hexGasLimit = rpcResponse.result?.asString
|
||||
?: throw Exception("eth_estimateGas 结果为空")
|
||||
|
||||
// 将十六进制转换为十进制字符串
|
||||
val gasLimitBigInt = BigInteger(hexGasLimit.removePrefix("0x"), 16)
|
||||
return gasLimitBigInt.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 PROXY 结构哈希,参考 builder-relayer-client builder/proxy.ts createStructHash
|
||||
* concat: "rlx:" + from + to + data + txFee + gasPrice + gasLimit + nonce + relayHub + relay, then keccak256
|
||||
*/
|
||||
private fun createProxyStructHash(
|
||||
from: String,
|
||||
to: String,
|
||||
data: String,
|
||||
txFee: String,
|
||||
gasPrice: String,
|
||||
gasLimit: String,
|
||||
nonce: String,
|
||||
relayHubAddress: String,
|
||||
relayAddress: String
|
||||
): ByteArray {
|
||||
val rlxPrefix = "rlx:".toByteArray(Charsets.UTF_8)
|
||||
val fromBytes = EthereumUtils.hexToBytes(from.lowercase().removePrefix("0x").padStart(40, '0'))
|
||||
val toBytes = EthereumUtils.hexToBytes(to.lowercase().removePrefix("0x").padStart(40, '0'))
|
||||
val dataBytes = EthereumUtils.hexToBytes(data.removePrefix("0x"))
|
||||
val txFeeBytes = EthereumUtils.encodeUint256(BigInteger(txFee)).let { EthereumUtils.hexToBytes(it) }
|
||||
val gasPriceBytes = EthereumUtils.encodeUint256(BigInteger(gasPrice)).let { EthereumUtils.hexToBytes(it) }
|
||||
val gasLimitBytes = EthereumUtils.encodeUint256(BigInteger(gasLimit)).let { EthereumUtils.hexToBytes(it) }
|
||||
val nonceBytes = EthereumUtils.encodeUint256(BigInteger(nonce)).let { EthereumUtils.hexToBytes(it) }
|
||||
val relayHubBytes = EthereumUtils.hexToBytes(relayHubAddress.lowercase().removePrefix("0x").padStart(40, '0'))
|
||||
val relayBytes = EthereumUtils.hexToBytes(relayAddress.lowercase().removePrefix("0x").padStart(40, '0'))
|
||||
|
||||
val concat = rlxPrefix + fromBytes + toBytes + dataBytes + txFeeBytes + gasPriceBytes +
|
||||
gasLimitBytes + nonceBytes + relayHubBytes + relayBytes
|
||||
return EthereumUtils.keccak256(concat)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Builder Relayer 执行交易(Gasless)
|
||||
* 参考: builder-relayer-client/src/client.ts 的 execute 方法
|
||||
@@ -278,7 +525,7 @@ class RelayClientService(
|
||||
val redeemCallData = safeTx.data
|
||||
|
||||
// 获取 Proxy 的 nonce(通过 Builder Relayer API)
|
||||
val nonceResponse = relayerApi.getNonce(fromAddress, "SAFE")
|
||||
val nonceResponse = relayerApi.getNonce(fromAddress, RELAYER_TYPE_SAFE)
|
||||
if (!nonceResponse.isSuccessful || nonceResponse.body() == null) {
|
||||
val errorBody = nonceResponse.errorBody()?.string() ?: "未知错误"
|
||||
logger.error("获取 nonce 失败: code=${nonceResponse.code()}, body=$errorBody")
|
||||
@@ -345,7 +592,7 @@ class RelayClientService(
|
||||
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts)
|
||||
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
|
||||
val request = BuilderRelayerApi.TransactionRequest(
|
||||
type = "SAFE",
|
||||
type = RELAYER_TYPE_SAFE,
|
||||
from = fromAddress,
|
||||
to = safeTx.to,
|
||||
proxyWallet = proxyAddress,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 添加最大仓位金额配置到回测任务表
|
||||
ALTER TABLE backtest_task
|
||||
ADD COLUMN max_position_value DECIMAL(20, 8) COMMENT '最大仓位金额(USDC),NULL表示不启用';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- ============================================
|
||||
-- V32: 添加回测价格区间过滤字段
|
||||
-- 用于配置价格区间,仅在指定价格区间内的订单才会跟单
|
||||
-- ============================================
|
||||
|
||||
-- 添加价格区间字段到回测任务表
|
||||
ALTER TABLE backtest_task
|
||||
ADD COLUMN min_price DECIMAL(20, 8) NULL COMMENT '最低价格(可选),NULL表示不限制最低价',
|
||||
ADD COLUMN max_price DECIMAL(20, 8) NULL COMMENT '最高价格(可选),NULL表示不限制最高价';
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
-- ============================================
|
||||
-- V33: 唯一约束从 wallet_address 改为 proxy_address
|
||||
-- 允许同一 EOA 以不同代理类型(Magic/Safe)各导入一个账户,按代理地址去重
|
||||
-- ============================================
|
||||
|
||||
-- 将已存在账户的 wallet_type 统一为 safe(历史数据兼容)
|
||||
UPDATE wallet_accounts SET wallet_type = 'safe';
|
||||
|
||||
-- 删除 wallet_address 上的唯一约束(通过 KEY_COLUMN_USAGE 定位到该列的约束名)
|
||||
SET @uk_name = (SELECT kcu.CONSTRAINT_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
||||
JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
|
||||
ON kcu.TABLE_SCHEMA = tc.TABLE_SCHEMA AND kcu.TABLE_NAME = tc.TABLE_NAME AND kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
|
||||
WHERE kcu.TABLE_SCHEMA = DATABASE()
|
||||
AND kcu.TABLE_NAME = 'wallet_accounts'
|
||||
AND tc.CONSTRAINT_TYPE = 'UNIQUE'
|
||||
AND kcu.COLUMN_NAME = 'wallet_address'
|
||||
LIMIT 1);
|
||||
SET @sql = IF(@uk_name IS NOT NULL,
|
||||
CONCAT('ALTER TABLE wallet_accounts DROP INDEX ', @uk_name),
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 为 proxy_address 添加唯一约束(若已存在则跳过)
|
||||
SET @uk_exists = (SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'wallet_accounts'
|
||||
AND CONSTRAINT_TYPE = 'UNIQUE'
|
||||
AND CONSTRAINT_NAME = 'uk_wallet_accounts_proxy_address'
|
||||
LIMIT 1);
|
||||
SET @sql2 = IF(@uk_exists IS NULL,
|
||||
'ALTER TABLE wallet_accounts ADD UNIQUE KEY uk_wallet_accounts_proxy_address (proxy_address)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt2 FROM @sql2;
|
||||
EXECUTE stmt2;
|
||||
DEALLOCATE PREPARE stmt2;
|
||||
@@ -262,6 +262,7 @@ error.backtest.leader_not_found=Leader not found
|
||||
error.backtest.days_invalid=Backtest days must be between 1-15 days
|
||||
error.backtest.initial_balance_invalid=Invalid initial balance
|
||||
error.backtest.task_running=Backtest task is running, cannot delete
|
||||
error.backtest.task_not_completed=Re-run is only supported for completed backtest tasks
|
||||
error.server.backtest_create_failed=Failed to create backtest task
|
||||
error.server.backtest_update_failed=Failed to update backtest task
|
||||
error.server.backtest_delete_failed=Failed to delete backtest task
|
||||
@@ -272,6 +273,7 @@ error.server.backtest_execute_failed=Failed to execute backtest
|
||||
error.server.backtest_historical_data_fetch_failed=Failed to fetch historical data
|
||||
error.server.backtest_stop_failed=Failed to stop backtest task
|
||||
error.server.backtest_retry_failed=Failed to retry backtest task
|
||||
error.server.backtest_rerun_failed=Failed to re-run backtest with same config
|
||||
# Backtest Management
|
||||
backtest.title=Backtest Management
|
||||
backtest.create_task=Create Backtest
|
||||
|
||||
@@ -262,6 +262,7 @@ error.backtest.leader_not_found=Leader不存在
|
||||
error.backtest.days_invalid=回测天数必须在 1-15 天之间
|
||||
error.backtest.initial_balance_invalid=初始金额无效
|
||||
error.backtest.task_running=回测任务正在运行,无法删除
|
||||
error.backtest.task_not_completed=仅支持对已完成的回测任务重新测试
|
||||
error.server.backtest_create_failed=创建回测任务失败
|
||||
error.server.backtest_update_failed=更新回测任务失败
|
||||
error.server.backtest_delete_failed=删除回测任务失败
|
||||
@@ -272,6 +273,7 @@ error.server.backtest_execute_failed=回测执行失败
|
||||
error.server.backtest_historical_data_fetch_failed=历史数据获取失败
|
||||
error.server.backtest_stop_failed=停止回测任务失败
|
||||
error.server.backtest_retry_failed=重试回测任务失败
|
||||
error.server.backtest_rerun_failed=按配置重新测试失败
|
||||
# 回测管理
|
||||
backtest.title=回测管理
|
||||
backtest.create_task=新增回测
|
||||
|
||||
@@ -262,6 +262,7 @@ error.backtest.leader_not_found=Leader不存在
|
||||
error.backtest.days_invalid=回測天數必須在 1-15 天之間
|
||||
error.backtest.initial_balance_invalid=初始金額無效
|
||||
error.backtest.task_running=回測任務正在運行,無法刪除
|
||||
error.backtest.task_not_completed=僅支援對已完成的回測任務重新測試
|
||||
error.server.backtest_create_failed=創建回測任務失敗
|
||||
error.server.backtest_update_failed=更新回測任務失敗
|
||||
error.server.backtest_delete_failed=刪除回測任務失敗
|
||||
@@ -272,6 +273,7 @@ error.server.backtest_execute_failed=回測執行失敗
|
||||
error.server.backtest_historical_data_fetch_failed=歷史數據獲取失敗
|
||||
error.server.backtest_stop_failed=停止回測任務失敗
|
||||
error.server.backtest_retry_failed=重試回測任務失敗
|
||||
error.server.backtest_rerun_failed=依配置重新測試失敗
|
||||
# 回測管理
|
||||
backtest.title=回測管理
|
||||
backtest.create_task=新增回測
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# PolyHermes 一键部署脚本使用说明
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
- **可在任意目录运行** - 无需下载项目源码
|
||||
- **仅使用线上镜像** - 从 Docker Hub 拉取官方镜像
|
||||
- **自动下载配置** - 从 GitHub 下载最新的 `docker-compose.prod.yml`
|
||||
- **交互式配置** - 友好的问答式配置向导
|
||||
- **自动生成密钥** - 所有敏感配置回车自动生成安全随机值
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 一键安装(推荐)
|
||||
|
||||
**使用 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 环境
|
||||
- ⚙️ 交互式配置所有参数(支持回车使用默认值)
|
||||
- 🔐 自动生成安全的随机密钥
|
||||
- 🚀 自动下载最新镜像并部署
|
||||
|
||||
**或者直接通过管道运行(不保存文件):**
|
||||
```bash
|
||||
# curl 方式
|
||||
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash
|
||||
|
||||
# wget 方式
|
||||
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -qO- https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash
|
||||
```
|
||||
|
||||
### 方式一:直接下载脚本运行
|
||||
|
||||
```bash
|
||||
# 下载脚本
|
||||
curl -O https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh
|
||||
|
||||
# 添加执行权限
|
||||
chmod +x deploy-interactive.sh
|
||||
|
||||
# 运行
|
||||
./deploy-interactive.sh
|
||||
```
|
||||
|
||||
### 方式二:在项目目录中运行
|
||||
|
||||
```bash
|
||||
git clone https://github.com/WrBug/PolyHermes.git
|
||||
cd PolyHermes
|
||||
./deploy-interactive.sh
|
||||
```
|
||||
|
||||
## 📝 使用流程
|
||||
|
||||
运行脚本后,会引导你完成以下步骤:
|
||||
|
||||
```
|
||||
步骤 1: 环境检查 → 检查 Docker/Docker Compose
|
||||
步骤 2: 配置收集 → 交互式输入配置(可全部回车使用默认)
|
||||
步骤 3: 获取部署配置 → 从 GitHub 下载 docker-compose.prod.yml
|
||||
步骤 4: 生成环境变量文件 → 自动生成 .env
|
||||
步骤 5: 拉取 Docker 镜像 → 从 Docker Hub 拉取最新镜像
|
||||
步骤 6: 部署服务 → 启动容器
|
||||
步骤 7: 健康检查 → 验证服务是否正常运行
|
||||
```
|
||||
|
||||
## ⚡ 最简单的使用方式
|
||||
|
||||
**所有配置项直接回车使用默认值**,脚本会自动:
|
||||
- 使用端口 80(应用)和 3307(MySQL)
|
||||
- 生成 32 字符的数据库密码
|
||||
- 生成 128 字符的 JWT 密钥
|
||||
- 生成 64 字符的管理员重置密钥
|
||||
- 生成 64 字符的加密密钥
|
||||
- 配置合理的日志级别
|
||||
|
||||
### 交互示例
|
||||
|
||||
脚本会逐项提示你输入配置,**直接按回车即可跳过使用默认值**:
|
||||
|
||||
```
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
步骤 2: 配置收集
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成
|
||||
|
||||
⚠ 密钥配置:回车将自动生成安全的随机密钥
|
||||
⚠ 其他配置:回车将使用括号中的默认值
|
||||
|
||||
【基础配置】
|
||||
将配置:服务器端口、MySQL端口、时区
|
||||
➤ 服务器端口 [默认: 80]: ⏎
|
||||
➤ MySQL 端口(外部访问) [默认: 3307]: ⏎
|
||||
➤ 时区 [默认: Asia/Shanghai]: ⏎
|
||||
|
||||
【数据库配置】
|
||||
将配置:数据库用户名、数据库密码
|
||||
➤ 数据库用户名 [默认: root]: ⏎
|
||||
➤ 数据库密码 [回车自动生成]: ⏎
|
||||
[✓] 已自动生成数据库密码(32字符)
|
||||
|
||||
【安全配置】
|
||||
将配置:JWT密钥、管理员密码重置密钥、数据加密密钥
|
||||
➤ JWT 密钥 [回车自动生成]: ⏎
|
||||
[✓] 已自动生成 JWT 密钥(128字符)
|
||||
➤ 管理员密码重置密钥 [回车自动生成]: ⏎
|
||||
[✓] 已自动生成管理员重置密钥(64字符)
|
||||
➤ 加密密钥(用于加密 API Key) [回车自动生成]: ⏎
|
||||
[✓] 已自动生成加密密钥(64字符)
|
||||
|
||||
【日志配置】
|
||||
将配置:Root日志级别、应用日志级别
|
||||
可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
➤ Root 日志级别(第三方库) [默认: WARN]: ⏎
|
||||
➤ 应用日志级别 [默认: INFO]: ⏎
|
||||
|
||||
【其他配置】
|
||||
将配置:运行环境、自动更新策略、GitHub仓库
|
||||
➤ Spring Profile [默认: prod]: ⏎
|
||||
➤ 允许预发布版本更新 (true/false) [默认: false]: ⏎
|
||||
➤ GitHub 仓库 [默认: WrBug/PolyHermes]: ⏎
|
||||
```
|
||||
|
||||
## 🔧 脚本生成的文件
|
||||
|
||||
脚本运行后会在当前目录生成:
|
||||
|
||||
1. **docker-compose.prod.yml** - 从 GitHub 下载的 Docker Compose 配置文件(始终保持最新)
|
||||
2. **.env** - 根据你的配置自动生成的环境变量文件
|
||||
|
||||
这两个文件包含了运行 PolyHermes 所需的全部配置。
|
||||
|
||||
## 🌐 部署后管理
|
||||
|
||||
### 快速更新(推荐)
|
||||
|
||||
如果已有配置文件,再次运行脚本时会自动检测并询问:
|
||||
|
||||
```bash
|
||||
./deploy-interactive.sh
|
||||
```
|
||||
|
||||
```
|
||||
【检测到现有配置】
|
||||
发现已存在的 .env 配置文件
|
||||
|
||||
是否使用现有配置直接更新镜像?[Y/n]: ⏎
|
||||
```
|
||||
|
||||
- **回车或输入 Y**:使用现有配置,直接拉取最新镜像并更新
|
||||
- **输入 N**:重新配置(会备份现有配置)
|
||||
|
||||
### 手动管理命令
|
||||
|
||||
```bash
|
||||
# 查看服务状态
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
|
||||
# 查看日志
|
||||
docker compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# 重启服务
|
||||
docker compose -f docker-compose.prod.yml restart
|
||||
|
||||
# 停止服务
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
|
||||
# 更新到最新版本
|
||||
docker pull wrbug/polyhermes:latest
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## 🔐 安全建议
|
||||
|
||||
- **保护 .env 文件**:其中包含敏感信息,切勿提交到版本控制
|
||||
- **定期备份数据库**:数据存储在 Docker volume `mysql-data` 中
|
||||
- **生产环境配置 HTTPS**:建议使用 Nginx 或 Caddy 作为反向代理
|
||||
|
||||
## 📞 获取支持
|
||||
|
||||
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
|
||||
- [问题反馈](https://github.com/WrBug/PolyHermes/issues)
|
||||
- [完整部署文档](docs/zh/DEPLOYMENT_GUIDE.md)
|
||||
Executable
+510
@@ -0,0 +1,510 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ========================================
|
||||
# PolyHermes 交互式一键部署脚本
|
||||
# ========================================
|
||||
# 功能:
|
||||
# - 交互式配置环境变量
|
||||
# - 自动生成安全密钥
|
||||
# - 使用 Docker Hub 线上镜像部署
|
||||
# - 支持配置预检和回滚
|
||||
# ========================================
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 打印函数
|
||||
info() {
|
||||
echo -e "${GREEN}[✓]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[⚠]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[✗]${NC} $1"
|
||||
}
|
||||
|
||||
title() {
|
||||
echo -e "${CYAN}${1}${NC}"
|
||||
}
|
||||
|
||||
# 生成随机密钥
|
||||
generate_secret() {
|
||||
local length=${1:-32}
|
||||
if command -v openssl &> /dev/null; then
|
||||
openssl rand -hex $length
|
||||
else
|
||||
cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w $((length * 2)) | head -n 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 生成随机端口号(10000-60000之间)
|
||||
generate_random_port() {
|
||||
echo $((10000 + RANDOM % 50001))
|
||||
}
|
||||
|
||||
# 读取用户输入(支持默认值)
|
||||
read_input() {
|
||||
local prompt="$1"
|
||||
local default="$2"
|
||||
local is_secret="$3"
|
||||
local value=""
|
||||
|
||||
# 构建提示信息(不使用颜色,因为 read -p 可能不支持)
|
||||
local prompt_text=""
|
||||
if [ -n "$default" ]; then
|
||||
if [ "$is_secret" = "secret" ]; then
|
||||
prompt_text="${prompt} [回车自动生成]: "
|
||||
else
|
||||
prompt_text="${prompt} [默认: ${default}]: "
|
||||
fi
|
||||
else
|
||||
prompt_text="${prompt}: "
|
||||
fi
|
||||
|
||||
# 使用 read -p 确保提示正确显示
|
||||
read -r -p "$prompt_text" value
|
||||
|
||||
# 如果用户没有输入,使用默认值
|
||||
if [ -z "$value" ]; then
|
||||
if [ "$is_secret" = "secret" ] && [ -z "$default" ]; then
|
||||
# 自动生成密钥
|
||||
case "$prompt" in
|
||||
*JWT*)
|
||||
value=$(generate_secret 64)
|
||||
# 输出到 stderr,避免被捕获到返回值中
|
||||
info "已自动生成 JWT 密钥(128字符)" >&2
|
||||
;;
|
||||
*管理员*|*ADMIN*)
|
||||
value=$(generate_secret 32)
|
||||
info "已自动生成管理员重置密钥(64字符)" >&2
|
||||
;;
|
||||
*加密*|*CRYPTO*)
|
||||
value=$(generate_secret 32)
|
||||
info "已自动生成加密密钥(64字符)" >&2
|
||||
;;
|
||||
*数据库密码*|*DB_PASSWORD*)
|
||||
value=$(generate_secret 16)
|
||||
info "已自动生成数据库密码(32字符)" >&2
|
||||
;;
|
||||
*)
|
||||
value="$default"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
value="$default"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$value"
|
||||
}
|
||||
|
||||
# 检查 Docker 环境
|
||||
check_docker() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 1: 环境检查"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# 检查 Docker
|
||||
if ! command -v docker &> /dev/null; then
|
||||
error "Docker 未安装"
|
||||
echo ""
|
||||
info "请先安装 Docker:"
|
||||
info " macOS: brew install docker"
|
||||
info " Ubuntu/Debian: apt-get install docker.io"
|
||||
info " CentOS/RHEL: yum install docker"
|
||||
exit 1
|
||||
fi
|
||||
info "Docker 已安装: $(docker --version | head -1)"
|
||||
|
||||
# 检查 Docker Compose
|
||||
if docker compose version &> /dev/null 2>&1; then
|
||||
info "Docker Compose 已安装: $(docker compose version)"
|
||||
elif command -v docker-compose &> /dev/null; then
|
||||
info "Docker Compose 已安装: $(docker-compose --version)"
|
||||
else
|
||||
error "Docker Compose 未安装"
|
||||
echo ""
|
||||
info "请先安装 Docker Compose:"
|
||||
info " https://docs.docker.com/compose/install/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 Docker 守护进程
|
||||
if ! docker info &> /dev/null; then
|
||||
error "Docker 守护进程未运行"
|
||||
info "请启动 Docker 服务:"
|
||||
info " macOS: 打开 Docker Desktop"
|
||||
info " Linux: systemctl start docker"
|
||||
exit 1
|
||||
fi
|
||||
info "Docker 守护进程运行正常"
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 交互式配置收集
|
||||
collect_configuration() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 2: 配置收集"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
info "💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成"
|
||||
echo ""
|
||||
warn "密钥配置:回车将自动生成安全的随机密钥"
|
||||
warn "其他配置:回车将使用括号中的默认值"
|
||||
echo ""
|
||||
|
||||
# 基础配置
|
||||
title "【基础配置】"
|
||||
echo -e "${CYAN}将配置:服务器端口、MySQL端口、时区${NC}"
|
||||
# 生成随机端口作为默认值
|
||||
DEFAULT_PORT=$(generate_random_port)
|
||||
SERVER_PORT=$(read_input "➤ 服务器端口" "$DEFAULT_PORT")
|
||||
MYSQL_PORT=$(read_input "➤ MySQL 端口(外部访问)" "3307")
|
||||
TZ=$(read_input "➤ 时区" "Asia/Shanghai")
|
||||
echo ""
|
||||
|
||||
# 数据库配置
|
||||
title "【数据库配置】"
|
||||
echo -e "${CYAN}将配置:数据库用户名、数据库密码${NC}"
|
||||
echo -e "${YELLOW}💡 提示:密码留空将自动生成 32 字符的安全随机密码${NC}"
|
||||
DB_USERNAME=$(read_input "➤ 数据库用户名" "root")
|
||||
DB_PASSWORD=$(read_input "➤ 数据库密码" "" "secret")
|
||||
echo ""
|
||||
|
||||
# 安全配置
|
||||
title "【安全配置】"
|
||||
echo -e "${CYAN}将配置:JWT密钥、管理员密码重置密钥、数据加密密钥${NC}"
|
||||
echo -e "${YELLOW}💡 提示:留空将自动生成高强度随机密钥(推荐)${NC}"
|
||||
JWT_SECRET=$(read_input "➤ JWT 密钥" "" "secret")
|
||||
ADMIN_RESET_PASSWORD_KEY=$(read_input "➤ 管理员密码重置密钥" "" "secret")
|
||||
CRYPTO_SECRET_KEY=$(read_input "➤ 加密密钥(用于加密 API Key)" "" "secret")
|
||||
echo ""
|
||||
|
||||
# 日志配置
|
||||
title "【日志配置】"
|
||||
echo -e "${CYAN}将配置:Root日志级别、应用日志级别${NC}"
|
||||
echo -e "${YELLOW}可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF${NC}"
|
||||
LOG_LEVEL_ROOT=$(read_input "➤ Root 日志级别(第三方库)" "WARN")
|
||||
LOG_LEVEL_APP=$(read_input "➤ 应用日志级别" "INFO")
|
||||
echo ""
|
||||
|
||||
# 自动设置不需要用户输入的配置
|
||||
SPRING_PROFILES_ACTIVE="prod"
|
||||
ALLOW_PRERELEASE="false"
|
||||
GITHUB_REPO="WrBug/PolyHermes"
|
||||
}
|
||||
|
||||
# 下载 docker-compose.prod.yml(如果不存在)
|
||||
download_docker_compose_file() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 3: 获取部署配置"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
if [ -f "docker-compose.prod.yml" ]; then
|
||||
info "检测到现有 docker-compose.prod.yml,跳过下载"
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "正在从 GitHub 下载 docker-compose.prod.yml..."
|
||||
|
||||
# GitHub raw 文件链接
|
||||
local compose_url="https://raw.githubusercontent.com/WrBug/PolyHermes/main/docker-compose.prod.yml"
|
||||
|
||||
# 尝试下载
|
||||
if curl -fsSL "$compose_url" -o docker-compose.prod.yml; then
|
||||
info "docker-compose.prod.yml 下载成功"
|
||||
else
|
||||
error "docker-compose.prod.yml 下载失败"
|
||||
warn "请检查网络连接或手动下载:"
|
||||
warn " $compose_url"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 生成 .env 文件
|
||||
generate_env_file() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 4: 生成环境变量文件"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# 备份现有 .env 文件
|
||||
if [ -f ".env" ]; then
|
||||
BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
cp .env "$BACKUP_FILE"
|
||||
warn "已备份现有配置文件到: $BACKUP_FILE"
|
||||
fi
|
||||
|
||||
# 生成新的 .env 文件
|
||||
cat > .env <<EOF
|
||||
# ========================================
|
||||
# PolyHermes 生产环境配置
|
||||
# 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
|
||||
# ========================================
|
||||
|
||||
# ============================================
|
||||
# 基础配置
|
||||
# ============================================
|
||||
TZ=${TZ}
|
||||
SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE}
|
||||
SERVER_PORT=${SERVER_PORT}
|
||||
MYSQL_PORT=${MYSQL_PORT}
|
||||
|
||||
# ============================================
|
||||
# 数据库配置
|
||||
# ============================================
|
||||
DB_URL=jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true
|
||||
DB_USERNAME=${DB_USERNAME}
|
||||
DB_PASSWORD=${DB_PASSWORD}
|
||||
|
||||
# ============================================
|
||||
# 安全配置(请妥善保管)
|
||||
# ============================================
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY}
|
||||
CRYPTO_SECRET_KEY=${CRYPTO_SECRET_KEY}
|
||||
|
||||
# ============================================
|
||||
# 日志配置
|
||||
# ============================================
|
||||
LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT}
|
||||
LOG_LEVEL_APP=${LOG_LEVEL_APP}
|
||||
|
||||
# ============================================
|
||||
# 其他配置
|
||||
# ============================================
|
||||
ALLOW_PRERELEASE=${ALLOW_PRERELEASE}
|
||||
GITHUB_REPO=${GITHUB_REPO}
|
||||
EOF
|
||||
|
||||
info "配置文件已生成: .env"
|
||||
echo ""
|
||||
|
||||
# 显示配置摘要
|
||||
title "【配置摘要】"
|
||||
echo " 服务器端口: ${SERVER_PORT}"
|
||||
echo " MySQL 端口: ${MYSQL_PORT}"
|
||||
echo " 时区: ${TZ}"
|
||||
echo " 数据库用户: ${DB_USERNAME}"
|
||||
echo " 数据库密码: ${DB_PASSWORD:0:8}... (已隐藏)"
|
||||
echo " JWT 密钥: ${JWT_SECRET:0:16}... (已隐藏)"
|
||||
echo " 管理员重置密钥: ${ADMIN_RESET_PASSWORD_KEY:0:16}... (已隐藏)"
|
||||
echo " 加密密钥: ${CRYPTO_SECRET_KEY:0:16}... (已隐藏)"
|
||||
echo " 日志级别: Root=${LOG_LEVEL_ROOT}, App=${LOG_LEVEL_APP}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 拉取镜像
|
||||
pull_images() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 5: 拉取 Docker 镜像"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
info "正在从 Docker Hub 拉取最新镜像..."
|
||||
|
||||
# 拉取应用镜像
|
||||
if docker pull wrbug/polyhermes:latest; then
|
||||
info "应用镜像拉取成功: wrbug/polyhermes:latest"
|
||||
else
|
||||
error "应用镜像拉取失败"
|
||||
warn "可能的原因:"
|
||||
warn " 1. 网络连接问题"
|
||||
warn " 2. Docker Hub 服务异常"
|
||||
warn " 3. 镜像不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 拉取 MySQL 镜像
|
||||
if docker pull mysql:8.2; then
|
||||
info "MySQL 镜像拉取成功: mysql:8.2"
|
||||
else
|
||||
warn "MySQL 镜像拉取失败,将在启动时自动下载"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 部署服务
|
||||
deploy_services() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 6: 部署服务"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# 停止现有服务
|
||||
if docker compose -f docker-compose.prod.yml ps -q 2>/dev/null | grep -q .; then
|
||||
warn "检测到正在运行的服务,正在停止..."
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
info "已停止现有服务"
|
||||
fi
|
||||
|
||||
# 启动服务
|
||||
info "正在启动服务..."
|
||||
if docker compose -f docker-compose.prod.yml up -d; then
|
||||
info "服务启动成功"
|
||||
else
|
||||
error "服务启动失败"
|
||||
error "请检查日志: docker compose -f docker-compose.prod.yml logs"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
health_check() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 7: 健康检查"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
info "等待服务启动(最多等待 60 秒)..."
|
||||
|
||||
local max_attempts=12
|
||||
local attempt=0
|
||||
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
attempt=$((attempt + 1))
|
||||
|
||||
# 检查容器状态
|
||||
if docker compose -f docker-compose.prod.yml ps | grep -q "Up"; then
|
||||
info "容器运行正常"
|
||||
|
||||
# 检查应用是否响应
|
||||
if curl -s -o /dev/null -w "%{http_code}" http://localhost:${SERVER_PORT} | grep -q "200\|302\|401"; then
|
||||
info "应用响应正常"
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -n "."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo ""
|
||||
warn "健康检查超时,请手动检查服务状态"
|
||||
warn "查看日志: docker compose -f docker-compose.prod.yml logs -f"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 显示部署信息
|
||||
show_deployment_info() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 部署完成!"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
info "访问地址: ${GREEN}http://localhost:${SERVER_PORT}${NC}"
|
||||
echo ""
|
||||
|
||||
title "【常用命令】"
|
||||
echo -e " 查看服务状态: ${CYAN}docker compose -f docker-compose.prod.yml ps${NC}"
|
||||
echo -e " 查看日志: ${CYAN}docker compose -f docker-compose.prod.yml logs -f${NC}"
|
||||
echo -e " 停止服务: ${CYAN}docker compose -f docker-compose.prod.yml down${NC}"
|
||||
echo -e " 重启服务: ${CYAN}docker compose -f docker-compose.prod.yml restart${NC}"
|
||||
echo -e " 更新镜像: ${CYAN}docker pull wrbug/polyhermes:latest && docker compose -f docker-compose.prod.yml up -d${NC}"
|
||||
echo ""
|
||||
|
||||
title "【数据库连接信息】"
|
||||
echo -e " 主机: ${CYAN}localhost${NC}"
|
||||
echo -e " 端口: ${CYAN}${MYSQL_PORT}${NC}"
|
||||
echo -e " 数据库: ${CYAN}polyhermes${NC}"
|
||||
echo -e " 用户名: ${CYAN}${DB_USERNAME}${NC}"
|
||||
echo -e " 密码: ${CYAN}${DB_PASSWORD}${NC}"
|
||||
echo ""
|
||||
|
||||
title "【管理员重置密钥】"
|
||||
echo -e " 重置密钥: ${CYAN}${ADMIN_RESET_PASSWORD_KEY}${NC}"
|
||||
echo -e " ${YELLOW}💡 此密钥用于重置管理员密码,请妥善保管${NC}"
|
||||
echo ""
|
||||
|
||||
warn "重要提示:"
|
||||
warn " 1. 请妥善保管 .env 文件,勿提交到版本控制系统"
|
||||
warn " 2. 定期备份数据库数据(位于 Docker volume: polyhermes_mysql-data)"
|
||||
warn " 3. 生产环境建议配置反向代理(如 Nginx)并启用 HTTPS"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
clear
|
||||
|
||||
echo ""
|
||||
title "========================================="
|
||||
title " PolyHermes 交互式一键部署脚本 "
|
||||
title "========================================="
|
||||
echo ""
|
||||
|
||||
# 执行部署流程
|
||||
check_docker
|
||||
|
||||
# 检查是否已存在 .env 文件
|
||||
if [ -f ".env" ]; then
|
||||
echo ""
|
||||
title "【检测到现有配置】"
|
||||
info "发现已存在的 .env 配置文件"
|
||||
echo ""
|
||||
echo -ne "${YELLOW}是否使用现有配置直接更新镜像?[Y/n]: ${NC}"
|
||||
read -r use_existing
|
||||
use_existing=${use_existing:-Y}
|
||||
|
||||
if [[ "$use_existing" =~ ^[Yy]$ ]]; then
|
||||
info "将使用现有配置,跳过配置步骤"
|
||||
echo ""
|
||||
# 从现有 .env 文件读取必要的变量
|
||||
source .env 2>/dev/null || true
|
||||
else
|
||||
warn "将重新配置,现有配置将被备份"
|
||||
echo ""
|
||||
collect_configuration
|
||||
fi
|
||||
else
|
||||
collect_configuration
|
||||
fi
|
||||
|
||||
download_docker_compose_file
|
||||
|
||||
# 只有在重新配置时才生成新的 .env 文件
|
||||
if [[ ! "$use_existing" =~ ^[Yy]$ ]] || [ ! -f ".env" ]; then
|
||||
generate_env_file
|
||||
fi
|
||||
|
||||
# 确认部署
|
||||
echo ""
|
||||
title "【确认部署】"
|
||||
echo -ne "${YELLOW}是否开始部署?[Y/n](回车默认为是): ${NC}"
|
||||
read -r confirm
|
||||
|
||||
# 默认为 Y,只有明确输入 n/N 才取消
|
||||
confirm=${confirm:-Y}
|
||||
if [[ "$confirm" =~ ^[Nn]$ ]]; then
|
||||
warn "部署已取消"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
pull_images
|
||||
deploy_services
|
||||
health_check
|
||||
show_deployment_info
|
||||
|
||||
info "部署流程已完成!"
|
||||
}
|
||||
|
||||
# 捕获 Ctrl+C
|
||||
trap 'echo ""; warn "部署已中断"; exit 1' INT
|
||||
|
||||
# 运行主函数
|
||||
main "$@"
|
||||
@@ -0,0 +1,335 @@
|
||||
# PolyHermes 一键部署指南
|
||||
|
||||
本文档介绍如何使用交互式一键部署脚本快速部署 PolyHermes 应用。
|
||||
|
||||
## 📋 前置要求
|
||||
|
||||
### 必需软件
|
||||
|
||||
- **Docker**: 版本 20.10 或更高
|
||||
- **Docker Compose**: 版本 2.0 或更高(或 `docker-compose` v1.29+)
|
||||
|
||||
### 环境准备
|
||||
|
||||
```bash
|
||||
# macOS 安装 Docker
|
||||
brew install docker
|
||||
|
||||
# Ubuntu/Debian 安装 Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
|
||||
# CentOS/RHEL 安装 Docker
|
||||
yum install docker-ce docker-ce-cli containerd.io
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 克隆项目(如果尚未克隆)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/WrBug/PolyHermes.git
|
||||
cd PolyHermes
|
||||
```
|
||||
|
||||
### 2. 运行部署脚本
|
||||
|
||||
```bash
|
||||
./deploy-interactive.sh
|
||||
```
|
||||
|
||||
### 3. 按提示配置
|
||||
|
||||
脚本会引导你完成以下配置:
|
||||
|
||||
#### 基础配置
|
||||
- **服务器端口**:应用对外暴露的端口(默认:80)
|
||||
- **MySQL 端口**:数据库外部访问端口(默认:3307,避免与本地 MySQL 冲突)
|
||||
- **时区**:服务器时区(默认:Asia/Shanghai)
|
||||
|
||||
#### 数据库配置
|
||||
- **数据库用户名**:默认为 `root`
|
||||
- **数据库密码**:回车自动生成 32 字符随机密码
|
||||
|
||||
#### 安全配置(关键)
|
||||
- **JWT 密钥**:回车自动生成 128 字符随机密钥
|
||||
- **管理员密码重置密钥**:回车自动生成 64 字符随机密钥
|
||||
- **加密密钥**:用于加密 API Key,回车自动生成 64 字符随机密钥
|
||||
|
||||
#### 日志配置
|
||||
- **Root 日志级别**:第三方库日志级别(默认:WARN)
|
||||
- 可选:`TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `OFF`
|
||||
- **应用日志级别**:应用代码日志级别(默认:INFO)
|
||||
|
||||
#### 其他配置
|
||||
- **Spring Profile**:环境配置(默认:prod)
|
||||
- **允许预发布版本**:是否允许自动更新到预发布版本(默认:false)
|
||||
- **GitHub 仓库**:项目仓库地址(默认:WrBug/PolyHermes)
|
||||
|
||||
### 4. 确认并部署
|
||||
|
||||
配置完成后,脚本会:
|
||||
1. 生成 `.env` 配置文件
|
||||
2. 显示配置摘要
|
||||
3. 请求确认部署
|
||||
4. 拉取 Docker Hub 镜像
|
||||
5. 启动服务
|
||||
6. 执行健康检查
|
||||
|
||||
## 📝 配置示例
|
||||
|
||||
### 使用默认值部署(推荐)
|
||||
|
||||
所有配置项直接回车,脚本会自动生成安全的随机密钥:
|
||||
|
||||
```
|
||||
服务器端口 [默认: 80]: ⏎
|
||||
MySQL 端口(外部访问) [默认: 3307]: ⏎
|
||||
时区 [默认: Asia/Shanghai]: ⏎
|
||||
数据库用户名 [默认: root]: ⏎
|
||||
数据库密码 [回车自动生成]: ⏎
|
||||
JWT 密钥 [回车自动生成]: ⏎
|
||||
管理员密码重置密钥 [回车自动生成]: ⏎
|
||||
加密密钥(用于加密 API Key) [回车自动生成]: ⏎
|
||||
Root 日志级别(第三方库) [默认: WARN]: ⏎
|
||||
应用日志级别 [默认: INFO]: ⏎
|
||||
Spring Profile [默认: prod]: ⏎
|
||||
允许预发布版本更新 [默认: false]: ⏎
|
||||
GitHub 仓库 [默认: WrBug/PolyHermes]: ⏎
|
||||
```
|
||||
|
||||
### 自定义端口部署
|
||||
|
||||
如果需要使用不同的端口:
|
||||
|
||||
```
|
||||
服务器端口 [默认: 80]: 8080⏎
|
||||
MySQL 端口(外部访问) [默认: 3307]: 33306⏎
|
||||
```
|
||||
|
||||
### 开发环境部署
|
||||
|
||||
启用 DEBUG 日志:
|
||||
|
||||
```
|
||||
Root 日志级别(第三方库) [默认: WARN]: DEBUG⏎
|
||||
应用日志级别 [默认: INFO]: DEBUG⏎
|
||||
```
|
||||
|
||||
## 🔧 部署后管理
|
||||
|
||||
### 访问应用
|
||||
|
||||
部署完成后,访问:
|
||||
|
||||
```
|
||||
http://localhost:80
|
||||
```
|
||||
|
||||
(或你配置的自定义端口)
|
||||
|
||||
### 常用命令
|
||||
|
||||
```bash
|
||||
# 查看服务状态
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
|
||||
# 查看实时日志
|
||||
docker compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# 仅查看应用日志
|
||||
docker compose -f docker-compose.prod.yml logs -f app
|
||||
|
||||
# 停止服务
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
|
||||
# 重启服务
|
||||
docker compose -f docker-compose.prod.yml restart
|
||||
|
||||
# 更新到最新版本
|
||||
docker pull wrbug/polyhermes:latest
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 数据库连接
|
||||
|
||||
使用配置的凭据连接到 MySQL:
|
||||
|
||||
```bash
|
||||
mysql -h 127.0.0.1 -P 3307 -u root -p
|
||||
# 输入你在部署时设置的数据库密码
|
||||
```
|
||||
|
||||
或使用图形化工具(如 DBeaver、Navicat):
|
||||
- **主机**: `localhost`
|
||||
- **端口**: `3307`(或你配置的端口)
|
||||
- **数据库**: `polyhermes`
|
||||
- **用户名**: `root`(或你配置的用户名)
|
||||
- **密码**: 部署时设置的密码(可在 `.env` 文件中查看)
|
||||
|
||||
## 🔐 安全最佳实践
|
||||
|
||||
### 保护配置文件
|
||||
|
||||
```bash
|
||||
# 设置 .env 文件权限
|
||||
chmod 600 .env
|
||||
|
||||
# 确保 .env 已添加到 .gitignore
|
||||
echo ".env" >> .gitignore
|
||||
```
|
||||
|
||||
### 定期更换密钥
|
||||
|
||||
生产环境建议定期更换安全密钥:
|
||||
|
||||
```bash
|
||||
# 生成新的 JWT 密钥(128字符)
|
||||
openssl rand -hex 64
|
||||
|
||||
# 生成新的管理员重置密钥(64字符)
|
||||
openssl rand -hex 32
|
||||
|
||||
# 更新 .env 文件后重启服务
|
||||
docker compose -f docker-compose.prod.yml restart
|
||||
```
|
||||
|
||||
### 备份数据库
|
||||
|
||||
```bash
|
||||
# 备份数据库
|
||||
docker exec polyhermes-mysql mysqldump -u root -p polyhermes > backup_$(date +%Y%m%d).sql
|
||||
|
||||
# 恢复数据库
|
||||
docker exec -i polyhermes-mysql mysql -u root -p polyhermes < backup_20260201.sql
|
||||
```
|
||||
|
||||
## 🌐 生产环境部署建议
|
||||
|
||||
### 1. 使用反向代理
|
||||
|
||||
建议使用 Nginx 或 Caddy 作为反向代理:
|
||||
|
||||
```nginx
|
||||
# Nginx 配置示例
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name polyhermes.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 配置防火墙
|
||||
|
||||
```bash
|
||||
# UFW (Ubuntu)
|
||||
ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
ufw enable
|
||||
|
||||
# Firewalld (CentOS)
|
||||
firewall-cmd --add-service=http --permanent
|
||||
firewall-cmd --add-service=https --permanent
|
||||
firewall-cmd --reload
|
||||
```
|
||||
|
||||
### 3. 启用自动更新
|
||||
|
||||
配置定时任务自动检查并更新镜像:
|
||||
|
||||
```bash
|
||||
# 创建更新脚本
|
||||
cat > /opt/polyhermes-update.sh <<'EOF'
|
||||
#!/bin/bash
|
||||
cd /path/to/PolyHermes
|
||||
docker pull wrbug/polyhermes:latest
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
EOF
|
||||
|
||||
chmod +x /opt/polyhermes-update.sh
|
||||
|
||||
# 添加到 crontab(每天凌晨 3 点检查更新)
|
||||
echo "0 3 * * * /opt/polyhermes-update.sh >> /var/log/polyhermes-update.log 2>&1" | crontab -
|
||||
```
|
||||
|
||||
## 🐛 故障排查
|
||||
|
||||
### 服务无法启动
|
||||
|
||||
```bash
|
||||
# 查看详细错误日志
|
||||
docker compose -f docker-compose.prod.yml logs
|
||||
|
||||
# 检查容器状态
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
```
|
||||
|
||||
### 数据库连接失败
|
||||
|
||||
```bash
|
||||
# 检查 MySQL 容器状态
|
||||
docker logs polyhermes-mysql
|
||||
|
||||
# 测试数据库连接
|
||||
docker exec polyhermes-mysql mysql -u root -p -e "SELECT 1"
|
||||
```
|
||||
|
||||
### 端口被占用
|
||||
|
||||
```bash
|
||||
# 查找占用端口的进程
|
||||
lsof -i :80
|
||||
# 或
|
||||
netstat -tulpn | grep :80
|
||||
|
||||
# 修改 SERVER_PORT 环境变量
|
||||
vim .env # 修改 SERVER_PORT=8080
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 镜像拉取失败
|
||||
|
||||
如果 Docker Hub 访问受限,可以配置镜像加速器:
|
||||
|
||||
```bash
|
||||
# 配置 Docker 镜像加速器
|
||||
sudo mkdir -p /etc/docker
|
||||
sudo tee /etc/docker/daemon.json <<-'EOF'
|
||||
{
|
||||
"registry-mirrors": [
|
||||
"https://docker.mirrors.ustc.edu.cn",
|
||||
"https://hub-mirror.c.163.com"
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
## 📚 更多资源
|
||||
|
||||
- [项目 README](../../README.md)
|
||||
- [发布日志](../../RELEASE.md)
|
||||
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
|
||||
- [问题反馈](https://github.com/WrBug/PolyHermes/issues)
|
||||
|
||||
## 📞 获取帮助
|
||||
|
||||
如遇到问题,请:
|
||||
|
||||
1. 查看上方的**故障排查**章节
|
||||
2. 检查 [GitHub Issues](https://github.com/WrBug/PolyHermes/issues)
|
||||
3. 提交新的 Issue 并附上日志输出
|
||||
|
||||
---
|
||||
|
||||
**祝部署顺利!** 🎉
|
||||
@@ -0,0 +1,45 @@
|
||||
# Polymarket Data API 游标分页验证
|
||||
|
||||
回测拉取 Leader 历史交易改用 **start 游标分页**(不再使用 offset),避免 `offset` 过大(如 3100+)时 API 报错。
|
||||
|
||||
## 规则
|
||||
|
||||
- `limit` 固定为 500(快速验证可用 50)。
|
||||
- 首次请求:`start` = 回测开始时间(秒),`end` = 回测结束时间(秒)。
|
||||
- 若本批返回 **500 条**:取本批中**最大 timestamp**,下一页 `start = max_timestamp`(**不加 1**:同一秒可能有多笔订单,会漏单)。
|
||||
- 若本批 **不足 500 条**:视为最后一页,不再请求。
|
||||
- 下一页会与上一页在「最大 timestamp」这一秒重叠,必须按 **tradeId(transactionHash)去重**。
|
||||
|
||||
## 快速验证(limit=50)
|
||||
|
||||
```bash
|
||||
# 环境变量(替换为实际值)
|
||||
USER="0x1979ae6b7e6534de9c4539d0c205e582ca637c9d"
|
||||
START=1769961432
|
||||
END=1770566207
|
||||
LIMIT=50
|
||||
|
||||
# 第 1 页(游标分页不传 offset)
|
||||
curl -s "https://data-api.polymarket.com/activity?user=${USER}&limit=${LIMIT}&type=TRADE&start=${START}&end=${END}&sortBy=TIMESTAMP&sortDirection=ASC" | jq 'length'
|
||||
# 若输出 50,则取本批最大 timestamp 作为下一页 start(不加 1,同一秒可能多笔)
|
||||
curl -s "https://data-api.polymarket.com/activity?user=${USER}&limit=${LIMIT}&type=TRADE&start=${START}&end=${END}&sortBy=TIMESTAMP&sortDirection=ASC" | jq 'max_by(.timestamp) | .timestamp'
|
||||
# 假设得到 1770000000,则下一页 start=1770000000(与上一批重叠,需按 tradeId 去重)
|
||||
|
||||
# 第 2 页(游标分页,不使用 offset)
|
||||
NEXT_START=1770000000
|
||||
curl -s "https://data-api.polymarket.com/activity?user=${USER}&limit=${LIMIT}&type=TRADE&start=${NEXT_START}&end=${END}&sortBy=TIMESTAMP&sortDirection=ASC" | jq 'length'
|
||||
# 若输出 < 50,则为最后一页
|
||||
```
|
||||
|
||||
## 单条命令示例(第 1 页,limit=50)
|
||||
|
||||
```bash
|
||||
curl -s "https://data-api.polymarket.com/activity?user=0x1979ae6b7e6534de9c4539d0c205e582ca637c9d&limit=50&type=TRADE&start=1769961432&end=1770566207&sortBy=TIMESTAMP&sortDirection=ASC"
|
||||
```
|
||||
|
||||
注意:**不要传 offset**,下一页 `start = 上一批最大 timestamp`(不加 1),同一秒多笔订单不丢,重叠记录按 tradeId 去重。
|
||||
|
||||
## 代码位置
|
||||
|
||||
- 拉取批次:`BacktestDataService.getLeaderHistoricalTradesBatch()`,返回 `LeaderTradesBatchResult(trades, nextCursorSeconds)`。
|
||||
- 执行循环:`BacktestExecutionService.executeBacktest()`,按 `cursorSeconds` 循环,并用 `seenTradeIds` 去重。
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Form, Input, Button, Radio, Space, Alert, Tooltip } from 'antd'
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Form, Input, Button, Radio, Space, Card, Spin, message, Alert, Steps, Tag } from 'antd'
|
||||
import { KeyOutlined, WalletOutlined, UserOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAccountStore } from '../store/accountStore'
|
||||
import {
|
||||
@@ -9,18 +9,19 @@ import {
|
||||
getPrivateKeyFromMnemonic,
|
||||
isValidWalletAddress,
|
||||
isValidPrivateKey,
|
||||
isValidMnemonic
|
||||
isValidMnemonic,
|
||||
formatUSDC
|
||||
} from '../utils'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { apiService } from '../services/api'
|
||||
import type { ProxyOption } from '../types'
|
||||
|
||||
type ImportType = 'privateKey' | 'mnemonic'
|
||||
type WalletType = 'magic' | 'safe'
|
||||
|
||||
interface AccountImportFormProps {
|
||||
form: any
|
||||
onSuccess?: (accountId: number) => void
|
||||
onCancel?: () => void
|
||||
showAlert?: boolean
|
||||
showCancelButton?: boolean
|
||||
}
|
||||
|
||||
@@ -28,23 +29,33 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
form,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
showAlert = true,
|
||||
showCancelButton = true
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { importAccount, loading } = useAccountStore()
|
||||
const [importType, setImportType] = useState<ImportType>('privateKey')
|
||||
const [walletType, setWalletType] = useState<WalletType>('safe')
|
||||
const [derivedAddress, setDerivedAddress] = useState<string>('')
|
||||
const [addressError, setAddressError] = useState<string>('')
|
||||
const [proxyOptions, setProxyOptions] = useState<ProxyOption[]>([])
|
||||
const [selectedProxyType, setSelectedProxyType] = useState<string>('')
|
||||
const [loadingProxyOptions, setLoadingProxyOptions] = useState<boolean>(false)
|
||||
const [step, setStep] = useState<'input' | 'select'>('input') // 步骤:输入 -> 选择代理地址
|
||||
|
||||
// 当私钥输入时,自动推导地址
|
||||
// 当私钥输入时,自动推导地址(不支持换行,自动去除换行符)
|
||||
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const privateKey = e.target.value.trim()
|
||||
const raw = e.target.value
|
||||
const normalized = raw.replace(/\r?\n/g, '')
|
||||
if (normalized !== raw) {
|
||||
form.setFieldsValue({ privateKey: normalized })
|
||||
}
|
||||
const privateKey = normalized.trim()
|
||||
if (!privateKey) {
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
setProxyOptions([])
|
||||
setSelectedProxyType('')
|
||||
setStep('input')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -52,6 +63,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
if (!isValidPrivateKey(privateKey)) {
|
||||
setAddressError(t('accountImport.privateKeyInvalid'))
|
||||
setDerivedAddress('')
|
||||
setProxyOptions([])
|
||||
setSelectedProxyType('')
|
||||
setStep('input')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,18 +76,34 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
|
||||
// 自动填充钱包地址字段
|
||||
form.setFieldsValue({ walletAddress: address })
|
||||
|
||||
// 延迟获取代理选项(避免频繁请求)
|
||||
setTimeout(() => {
|
||||
fetchProxyOptions(address, privateKey, null)
|
||||
}, 500)
|
||||
} catch (error: any) {
|
||||
setAddressError(error.message || t('accountImport.addressError'))
|
||||
setDerivedAddress('')
|
||||
setProxyOptions([])
|
||||
setSelectedProxyType('')
|
||||
setStep('input')
|
||||
}
|
||||
}
|
||||
|
||||
// 当助记词输入时,自动推导地址
|
||||
// 当助记词输入时,自动推导地址(不支持换行,换行符转为空格)
|
||||
const handleMnemonicChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const mnemonic = e.target.value.trim()
|
||||
const raw = e.target.value
|
||||
const normalized = raw.replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trimStart()
|
||||
if (/\r?\n/.test(raw)) {
|
||||
form.setFieldsValue({ mnemonic: normalized })
|
||||
}
|
||||
const mnemonic = normalized.trim()
|
||||
if (!mnemonic) {
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
setProxyOptions([])
|
||||
setSelectedProxyType('')
|
||||
setStep('input')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,6 +111,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
if (!isValidMnemonic(mnemonic)) {
|
||||
setAddressError(t('accountImport.mnemonicInvalid'))
|
||||
setDerivedAddress('')
|
||||
setProxyOptions([])
|
||||
setSelectedProxyType('')
|
||||
setStep('input')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -91,14 +124,85 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
|
||||
// 自动填充钱包地址字段
|
||||
form.setFieldsValue({ walletAddress: address })
|
||||
|
||||
// 延迟获取代理选项(避免频繁请求)
|
||||
setTimeout(() => {
|
||||
fetchProxyOptions(address, null, mnemonic)
|
||||
}, 500)
|
||||
} catch (error: any) {
|
||||
setAddressError(error.message || t('accountImport.addressErrorMnemonic'))
|
||||
setDerivedAddress('')
|
||||
setProxyOptions([])
|
||||
setSelectedProxyType('')
|
||||
setStep('input')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取代理地址选项
|
||||
const fetchProxyOptions = async (walletAddress: string, privateKey: string | null, mnemonic: string | null) => {
|
||||
if (!walletAddress || (!privateKey && !mnemonic)) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingProxyOptions(true)
|
||||
try {
|
||||
const response = await apiService.accounts.checkProxyOptions({
|
||||
walletAddress,
|
||||
privateKey: privateKey || undefined,
|
||||
mnemonic: mnemonic || undefined
|
||||
})
|
||||
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const options = response.data.data.options || []
|
||||
setProxyOptions(options)
|
||||
|
||||
// 如果有选项,进入选择步骤
|
||||
if (options.length > 0) {
|
||||
setStep('select')
|
||||
// 如果有资产,默认选择第一个有资产的选项
|
||||
const hasAssetsOption = options.find((opt: ProxyOption) => opt.hasAssets)
|
||||
if (hasAssetsOption) {
|
||||
setSelectedProxyType(hasAssetsOption.walletType)
|
||||
} else {
|
||||
// 否则选择第一个选项
|
||||
setSelectedProxyType(options[0].walletType)
|
||||
}
|
||||
} else {
|
||||
setStep('input')
|
||||
message.warning(t('accountImport.proxyOption.error') || '未获取到代理地址选项')
|
||||
}
|
||||
} else {
|
||||
setProxyOptions([])
|
||||
setStep('input')
|
||||
message.error(response.data.msg || '获取代理地址选项失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
setProxyOptions([])
|
||||
setStep('input')
|
||||
message.error(error.message || '获取代理地址选项失败')
|
||||
} finally {
|
||||
setLoadingProxyOptions(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换导入方式时重置状态
|
||||
useEffect(() => {
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
setProxyOptions([])
|
||||
setSelectedProxyType('')
|
||||
setStep('input')
|
||||
form.setFieldsValue({ walletAddress: '', privateKey: '', mnemonic: '' })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [importType])
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
// 如果还在输入步骤,需要先选择代理地址
|
||||
if (step === 'input' || !selectedProxyType) {
|
||||
return Promise.reject(new Error(t('accountImport.proxyOptionRequired')))
|
||||
}
|
||||
|
||||
let privateKey: string
|
||||
let walletAddress: string
|
||||
|
||||
@@ -124,14 +228,11 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
// 如果用户手动输入了地址,验证是否与推导的地址一致
|
||||
if (values.walletAddress) {
|
||||
if (values.walletAddress !== derivedAddressFromMnemonic) {
|
||||
// 地址不匹配,使用推导的地址(因为私钥是从助记词导出的,必须使用对应的地址)
|
||||
walletAddress = derivedAddressFromMnemonic
|
||||
} else {
|
||||
// 地址匹配,使用用户输入的地址
|
||||
walletAddress = values.walletAddress
|
||||
}
|
||||
} else {
|
||||
// 如果用户没有输入地址,使用推导的地址
|
||||
walletAddress = derivedAddressFromMnemonic
|
||||
}
|
||||
}
|
||||
@@ -145,14 +246,13 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
privateKey: privateKey,
|
||||
walletAddress: walletAddress,
|
||||
accountName: values.accountName,
|
||||
walletType: walletType
|
||||
walletType: selectedProxyType
|
||||
})
|
||||
|
||||
// 等待store更新
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 获取新添加的账户ID(通过API获取,因为store可能还没更新)
|
||||
const { apiService } = await import('../services/api')
|
||||
const accountsResponse = await apiService.accounts.list()
|
||||
if (accountsResponse.data.code === 0 && accountsResponse.data.data) {
|
||||
const newAccounts = accountsResponse.data.data.list || []
|
||||
@@ -160,76 +260,53 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
if (newAccount && onSuccess) {
|
||||
onSuccess(newAccount.id)
|
||||
} else if (onSuccess) {
|
||||
// 如果找不到账户,仍然调用onSuccess(可能在其他地方处理)
|
||||
onSuccess(0)
|
||||
}
|
||||
} else if (onSuccess) {
|
||||
// API调用失败,仍然调用onSuccess
|
||||
onSuccess(0)
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error & { code?: number }
|
||||
const isDuplicate = err?.code === 4601
|
||||
message.error(isDuplicate ? t('accountImport.duplicateAccount') : (err?.message ?? t('accountImport.importFailed')))
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
const currentStep = step === 'input' ? 0 : 1
|
||||
|
||||
return (
|
||||
<>
|
||||
{showAlert && (
|
||||
<Alert
|
||||
message={t('accountImport.securityTip')}
|
||||
description={t('accountImport.securityTipDesc')}
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: '24px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ padding: isMobile ? '0 4px' : '0 8px' }}>
|
||||
<Steps
|
||||
current={currentStep}
|
||||
size="small"
|
||||
style={{ marginBottom: 24 }}
|
||||
items={[
|
||||
{ title: t('accountImport.importMethod'), icon: <KeyOutlined /> },
|
||||
{ title: t('accountImport.selectProxyOption'), icon: <WalletOutlined /> },
|
||||
{ title: t('accountImport.accountName'), icon: <UserOutlined /> }
|
||||
]}
|
||||
/>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
<Form.Item label={t('accountImport.importMethod')}>
|
||||
<Form.Item label={t('accountImport.importMethod')} style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={importType}
|
||||
onChange={(e) => {
|
||||
setImportType(e.target.value)
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
form.setFieldsValue({ walletAddress: '' })
|
||||
}}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
<Radio value="privateKey">{t('accountImport.privateKey')}</Radio>
|
||||
<Radio value="mnemonic">{t('accountImport.mnemonic')}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
{t('accountImport.walletType')}{' '}
|
||||
<Tooltip
|
||||
title={t('accountImport.walletTypeHelp')}
|
||||
overlayInnerStyle={{ whiteSpace: 'pre-line', maxWidth: '300px' }}
|
||||
>
|
||||
<QuestionCircleOutlined style={{ color: '#999' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Radio.Group
|
||||
value={walletType}
|
||||
onChange={(e) => setWalletType(e.target.value)}
|
||||
>
|
||||
<Radio value="safe">
|
||||
{t('accountImport.walletTypeSafe')}
|
||||
</Radio>
|
||||
<Radio value="magic" disabled>
|
||||
{t('accountImport.walletTypeMagic')} {t('accountImport.magicNotSupported')}
|
||||
</Radio>
|
||||
<Radio.Button value="privateKey">{t('accountImport.privateKey')}</Radio.Button>
|
||||
<Radio.Button value="mnemonic">{t('accountImport.mnemonic')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
@@ -250,13 +327,15 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
}
|
||||
}
|
||||
]}
|
||||
help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')}
|
||||
help={addressError || ''}
|
||||
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
rows={2}
|
||||
placeholder={t('accountImport.privateKeyPlaceholder')}
|
||||
onChange={handlePrivateKeyChange}
|
||||
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
|
||||
disabled={loadingProxyOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -282,6 +361,7 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
<Input
|
||||
placeholder={t('accountImport.walletAddressPlaceholder')}
|
||||
readOnly={!!derivedAddress}
|
||||
disabled={loadingProxyOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
@@ -302,13 +382,15 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
}
|
||||
}
|
||||
]}
|
||||
help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')}
|
||||
help={addressError || ''}
|
||||
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
rows={2}
|
||||
placeholder={t('accountImport.mnemonicPlaceholder')}
|
||||
onChange={handleMnemonicChange}
|
||||
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
|
||||
disabled={loadingProxyOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -334,39 +416,143 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
<Input
|
||||
placeholder={t('accountImport.walletAddressPlaceholder')}
|
||||
readOnly={!!derivedAddress}
|
||||
disabled={loadingProxyOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 请求代理地址时的 loading 提示 */}
|
||||
{loadingProxyOptions && step === 'input' && (
|
||||
<Form.Item>
|
||||
<Alert
|
||||
message={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Spin size="small" />
|
||||
<span>{t('accountImport.loadingProxyOptions')}</span>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon={false}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* 代理地址选项选择 */}
|
||||
{step === 'select' && (
|
||||
<Form.Item
|
||||
label={t('accountImport.selectProxyOption')}
|
||||
required
|
||||
rules={[
|
||||
{
|
||||
validator: () => {
|
||||
if (!selectedProxyType) {
|
||||
return Promise.reject(new Error(t('accountImport.proxyOptionRequired')))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
style={{ marginBottom: 20 }}
|
||||
>
|
||||
{loadingProxyOptions ? (
|
||||
<div style={{ padding: '32px 0', textAlign: 'center' }}>
|
||||
<Spin tip={t('accountImport.loadingProxyOptions')} />
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
{proxyOptions.map((option) => {
|
||||
const isSelected = selectedProxyType === option.walletType
|
||||
const typeLabel = option.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'
|
||||
return (
|
||||
<Card
|
||||
key={option.walletType}
|
||||
hoverable
|
||||
onClick={() => setSelectedProxyType(option.walletType)}
|
||||
size="small"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: isSelected ? 'var(--ant-color-primary)' : undefined,
|
||||
borderWidth: isSelected ? 2 : 1,
|
||||
backgroundColor: isSelected ? 'var(--ant-color-primary-bg)' : undefined,
|
||||
transition: 'border-color 0.2s, background-color 0.2s'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||
<Space size="middle">
|
||||
<Radio checked={isSelected} />
|
||||
<Tag color={option.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'}>
|
||||
{typeLabel}
|
||||
</Tag>
|
||||
{option.hasAssets && (
|
||||
<span style={{ color: '#52c41a', fontSize: 12 }}>
|
||||
<CheckCircleOutlined /> {t('accountImport.proxyOption.hasAssets')}
|
||||
</span>
|
||||
)}
|
||||
{option.error && (
|
||||
<span style={{ color: 'var(--ant-color-error)', fontSize: 12 }}>
|
||||
<ExclamationCircleOutlined /> {t('accountImport.proxyOption.error')}
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
{!option.error && (
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--ant-color-primary)' }}>
|
||||
{formatUSDC(option.totalBalance)} USDC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, marginLeft: 28, fontSize: 12, color: 'var(--ant-color-text-secondary)', wordBreak: 'break-all' }}>
|
||||
{option.proxyAddress ? (
|
||||
<span style={{ fontFamily: 'monospace' }}>{option.proxyAddress}</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
{option.error && (
|
||||
<span style={{ color: 'var(--ant-color-error)', marginLeft: 8 }}>{option.error}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, marginLeft: 28, fontSize: 12, color: 'var(--ant-color-text-secondary)', lineHeight: 1.5 }}>
|
||||
{t('accountImport.proxyOption.proxyAddressHelp')}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={t('accountImport.accountName')}
|
||||
name="accountName"
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Input placeholder={t('accountImport.accountNamePlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Space size="middle">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
disabled={step !== 'select' || !selectedProxyType || loadingProxyOptions}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
style={isMobile ? { minHeight: 44 } : undefined}
|
||||
>
|
||||
{t('accountImport.importAccount')}
|
||||
</Button>
|
||||
{showCancelButton && onCancel && (
|
||||
<Button onClick={onCancel}>
|
||||
<Button onClick={onCancel} size={isMobile ? 'middle' : 'large'}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountImportForm
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
import { Select } from 'antd'
|
||||
import type { Leader } from '../types'
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
interface LeaderSelectProps {
|
||||
value?: number
|
||||
onChange?: (value: number | undefined) => void
|
||||
onSelectChange?: (value: number | undefined) => void // 选择变化时的回调
|
||||
leaders: Leader[]
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
showSearch?: boolean
|
||||
allowClear?: boolean
|
||||
notFoundContent?: React.ReactNode
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const LeaderSelect: React.FC<LeaderSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSelectChange,
|
||||
leaders,
|
||||
placeholder,
|
||||
disabled,
|
||||
showSearch = true,
|
||||
allowClear = false,
|
||||
notFoundContent,
|
||||
style
|
||||
}) => {
|
||||
// 处理选择变化
|
||||
const handleChange = (val: number | undefined) => {
|
||||
if (onChange) {
|
||||
onChange(val)
|
||||
}
|
||||
if (onSelectChange) {
|
||||
onSelectChange(val)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
showSearch={showSearch}
|
||||
allowClear={allowClear}
|
||||
notFoundContent={notFoundContent}
|
||||
style={style}
|
||||
optionFilterProp="label"
|
||||
optionLabelProp="label"
|
||||
>
|
||||
{leaders.map(leader => {
|
||||
const label = leader.leaderName || `Leader ${leader.id}`
|
||||
return (
|
||||
<Option key={leader.id} value={leader.id} label={label}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span>{label}</span>
|
||||
<span style={{ fontSize: '12px', color: '#999' }}>{leader.leaderAddress}</span>
|
||||
</div>
|
||||
</Option>
|
||||
)
|
||||
})}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export default LeaderSelect
|
||||
@@ -16,6 +16,10 @@
|
||||
"submit": "Submit",
|
||||
"reset": "Reset",
|
||||
"close": "Close",
|
||||
"create": "Create",
|
||||
"copy": "Copy",
|
||||
"copySuccess": "Copied successfully",
|
||||
"copyFailed": "Copy failed",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"actions": "Actions",
|
||||
@@ -49,6 +53,7 @@
|
||||
"accountId": "Account ID",
|
||||
"accountName": "Account Name",
|
||||
"walletAddress": "Wallet Address",
|
||||
"walletType": "Wallet Type",
|
||||
"balance": "Account Balance",
|
||||
"refreshBalance": "Refresh Balance",
|
||||
"apiCredentials": "API Credentials Configuration",
|
||||
@@ -118,6 +123,7 @@
|
||||
"importAccount": "Import Account",
|
||||
"accountName": "Account Name",
|
||||
"walletAddress": "Wallet Address",
|
||||
"walletType": "Wallet Type",
|
||||
"proxyAddress": "Proxy Wallet Address",
|
||||
"apiCredentials": "API Credentials",
|
||||
"balance": "Balance",
|
||||
@@ -174,8 +180,6 @@
|
||||
"accountImport": {
|
||||
"title": "Import Account",
|
||||
"back": "Back",
|
||||
"securityTip": "Security Tip",
|
||||
"securityTipDesc": "Private keys will be stored in the backend database. Please ensure database access is secure. HTTPS is recommended.",
|
||||
"importMethod": "Import Method",
|
||||
"privateKey": "Private Key",
|
||||
"mnemonic": "Mnemonic",
|
||||
@@ -209,9 +213,29 @@
|
||||
"addressErrorMnemonic": "Cannot derive address from mnemonic",
|
||||
"walletType": "Wallet Type",
|
||||
"walletTypeHelp": "Web3 Wallet: Polymarket accounts connected via browser wallets like MetaMask\nMagic: Polymarket accounts logged in via email or social accounts (Google, Twitter, etc.)",
|
||||
"walletTypeMagic": "Magic (Email/Social Login)",
|
||||
"walletTypeSafe": "Web3 Wallet",
|
||||
"magicNotSupported": "(Not Supported)"
|
||||
"loadingProxyOptions": "Loading proxy addresses and asset information...",
|
||||
"selectProxyOption": "Please select account type",
|
||||
"proxyOptionRequired": "Please select a proxy address",
|
||||
"proxyOption": {
|
||||
"magic": {
|
||||
"description": "Email/Social Login Account (Magic)",
|
||||
"title": "Magic Proxy Address"
|
||||
},
|
||||
"safe": {
|
||||
"description": "MetaMask Browser Wallet Account (Safe)",
|
||||
"title": "Safe Proxy Address"
|
||||
},
|
||||
"proxyAddress": "Proxy Address",
|
||||
"proxyAddressHelp": "The proxy address is the wallet address you actually use on Polymarket: Magic for email/social login accounts, Safe for browser wallet (e.g. MetaMask) accounts. Please select the one that matches how you log in to Polymarket.",
|
||||
"availableBalance": "Available Balance",
|
||||
"positionBalance": "Position Balance",
|
||||
"totalBalance": "Total Balance",
|
||||
"positionCount": "Position Count",
|
||||
"noAssets": "No Assets",
|
||||
"hasAssets": "Has Assets",
|
||||
"error": "Failed to fetch",
|
||||
"select": "Select this proxy address"
|
||||
}
|
||||
},
|
||||
"leader": {
|
||||
"title": "Leader Management",
|
||||
@@ -430,14 +454,20 @@
|
||||
"openWebsite": "Open Website",
|
||||
"all": "All",
|
||||
"copyTradingCount": "Copy Trading Count",
|
||||
"backtestCount": "Backtest Count",
|
||||
"copyTradingRelations": "{{count}} copy trading relations",
|
||||
"viewCopyTradings": "View Copy Tradings",
|
||||
"viewBacktests": "View Backtests",
|
||||
"createdAt": "Created At",
|
||||
"noData": "No Leader data",
|
||||
"fetchFailed": "Failed to get Leader list",
|
||||
"deleteSuccess": "Leader deleted successfully",
|
||||
"deleteFailed": "Failed to delete Leader",
|
||||
"deleteConfirm": "Are you sure you want to delete this Leader?",
|
||||
"deleteConfirmDesc": "This Leader has {{count}} copy trading relations, please delete them first"
|
||||
"deleteConfirmDesc": "This Leader has {{count}} copy trading relations, please delete them first",
|
||||
"openDetailFailed": "Failed to open detail",
|
||||
"noCopyTradings": "No copy trading configs",
|
||||
"noBacktests": "No backtests"
|
||||
},
|
||||
"leaderAdd": {
|
||||
"title": "Add Leader",
|
||||
@@ -510,6 +540,7 @@
|
||||
"totalBalance": "Total Balance",
|
||||
"positions": "Positions",
|
||||
"noPositions": "No positions",
|
||||
"market": "Market",
|
||||
"marketId": "Market ID",
|
||||
"side": "Side",
|
||||
"quantity": "Quantity",
|
||||
@@ -968,8 +999,10 @@
|
||||
"priceRange": "Price Range Mismatch",
|
||||
"maxPositionValue": "Exceeds Max Position Value",
|
||||
"marketEndDate": "Market End Date Exceeds Limit",
|
||||
"keywordFilter": "Keyword Filter",
|
||||
"unknown": "Unknown Reason"
|
||||
},
|
||||
"orderDetails": "Order Details",
|
||||
"noData": "No filtered orders",
|
||||
"noFilteredOrders": "No filtered orders"
|
||||
},
|
||||
@@ -1350,11 +1383,21 @@
|
||||
"fixedAmountRequired": "Please enter fixed amount",
|
||||
"fixedAmountInvalid": "Fixed amount must be greater than 0",
|
||||
"priceFilters": "Price Filters",
|
||||
"priceRange": "Price Range",
|
||||
"priceRangeTooltip": "Only copy Leader orders with price within the specified range. Leave empty to disable. Example: Enter 0.11 and 0.89 to only copy orders with price between 0.11 and 0.89; enter only max price 0.89 to only copy orders with price below 0.89; enter only min price 0.11 to only copy orders with price above 0.11.",
|
||||
"minPricePlaceholder": "Min price (leave empty to disable)",
|
||||
"maxPricePlaceholder": "Max price (leave empty to disable)",
|
||||
"keywordsPlaceholder": "Please enter keywords, press Enter to add",
|
||||
"maxPositionValuePlaceholder": "Leave empty to disable max position limit",
|
||||
"delaySecondsHint": "Delay execution to simulate real copy trading delay",
|
||||
"supportSellHint": "Whether to follow Leader sell orders",
|
||||
"sortBy": "Sort By",
|
||||
"sortOrder": "Sort Order",
|
||||
"createdAt": "Created At"
|
||||
"createdAt": "Created At",
|
||||
"rerun": "Re-run",
|
||||
"rerunConfirm": "Create a new backtest task with the same config?",
|
||||
"rerunTaskNamePlaceholder": "New task name (leave empty for \"Original name (copy)\")",
|
||||
"rerunSuccess": "New backtest task created",
|
||||
"rerunFailed": "Re-run failed"
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@
|
||||
"success": "成功",
|
||||
"failed": "失败",
|
||||
"close": "关闭",
|
||||
"create": "创建",
|
||||
"copy": "复制",
|
||||
"copySuccess": "复制成功",
|
||||
"copyFailed": "复制失败",
|
||||
"ascending": "升序",
|
||||
"descending": "降序",
|
||||
"day": "天"
|
||||
@@ -117,6 +121,7 @@
|
||||
"importAccount": "导入账户",
|
||||
"accountName": "账户名称",
|
||||
"walletAddress": "钱包地址",
|
||||
"walletType": "钱包类型",
|
||||
"proxyAddress": "代理钱包地址",
|
||||
"apiCredentials": "API 凭证",
|
||||
"balance": "余额",
|
||||
@@ -174,8 +179,6 @@
|
||||
"accountImport": {
|
||||
"title": "导入账户",
|
||||
"back": "返回",
|
||||
"securityTip": "安全提示",
|
||||
"securityTipDesc": "私钥将存储在后端数据库中,请确保数据库访问安全。建议使用 HTTPS 连接。",
|
||||
"importMethod": "导入方式",
|
||||
"privateKey": "私钥",
|
||||
"mnemonic": "助记词",
|
||||
@@ -209,9 +212,29 @@
|
||||
"addressErrorMnemonic": "无法从助记词推导地址",
|
||||
"walletType": "钱包类型",
|
||||
"walletTypeHelp": "Web3钱包:使用 MetaMask 等浏览器钱包连接的 Polymarket 账户\nMagic:通过邮箱或社交账号(如 Google、Twitter)登录的 Polymarket 账户",
|
||||
"walletTypeMagic": "Magic(邮箱/社交账号登录)",
|
||||
"walletTypeSafe": "Web3钱包",
|
||||
"magicNotSupported": "(暂不支持)"
|
||||
"loadingProxyOptions": "正在获取代理地址和资产信息...",
|
||||
"selectProxyOption": "请选择账户类型",
|
||||
"proxyOptionRequired": "请选择一个代理地址",
|
||||
"proxyOption": {
|
||||
"magic": {
|
||||
"description": "邮箱/社交账号登录账户(Magic)",
|
||||
"title": "Magic 代理地址"
|
||||
},
|
||||
"safe": {
|
||||
"description": "MetaMask 等浏览器钱包账户(Safe)",
|
||||
"title": "Safe 代理地址"
|
||||
},
|
||||
"proxyAddress": "代理地址",
|
||||
"proxyAddressHelp": "代理地址是您在 Polymarket 上实际使用的钱包地址:Magic 为邮箱/社交登录账户,Safe 为浏览器钱包(如 MetaMask)账户。请选择与您 Polymarket 登录方式一致的一项。",
|
||||
"availableBalance": "可用余额",
|
||||
"positionBalance": "仓位余额",
|
||||
"totalBalance": "总余额",
|
||||
"positionCount": "持仓数量",
|
||||
"noAssets": "无资产",
|
||||
"hasAssets": "有资产",
|
||||
"error": "获取失败",
|
||||
"select": "选择此代理地址"
|
||||
}
|
||||
},
|
||||
"leader": {
|
||||
"title": "Leader 管理",
|
||||
@@ -430,14 +453,20 @@
|
||||
"openWebsite": "打开网页",
|
||||
"all": "全部",
|
||||
"copyTradingCount": "跟单数",
|
||||
"backtestCount": "回测数",
|
||||
"copyTradingRelations": "{{count}} 个跟单关系",
|
||||
"viewCopyTradings": "查看跟单配置",
|
||||
"viewBacktests": "查看回测",
|
||||
"createdAt": "创建时间",
|
||||
"noData": "暂无 Leader 数据",
|
||||
"fetchFailed": "获取 Leader 列表失败",
|
||||
"deleteSuccess": "删除 Leader 成功",
|
||||
"deleteFailed": "删除 Leader 失败",
|
||||
"deleteConfirm": "确定要删除这个 Leader 吗?",
|
||||
"deleteConfirmDesc": "该 Leader 还有 {{count}} 个跟单关系,请先删除跟单关系"
|
||||
"deleteConfirmDesc": "该 Leader 还有 {{count}} 个跟单关系,请先删除跟单关系",
|
||||
"openDetailFailed": "打开详情失败",
|
||||
"noCopyTradings": "暂无跟单配置",
|
||||
"noBacktests": "暂无回测"
|
||||
},
|
||||
"leaderAdd": {
|
||||
"title": "添加 Leader",
|
||||
@@ -510,6 +539,7 @@
|
||||
"totalBalance": "总余额",
|
||||
"positions": "持仓列表",
|
||||
"noPositions": "暂无持仓",
|
||||
"market": "市场",
|
||||
"marketId": "市场ID",
|
||||
"side": "方向",
|
||||
"quantity": "数量",
|
||||
@@ -968,8 +998,10 @@
|
||||
"priceRange": "价格区间不符",
|
||||
"maxPositionValue": "超过最大仓位金额",
|
||||
"marketEndDate": "市场截止时间超出限制",
|
||||
"keywordFilter": "关键字过滤",
|
||||
"unknown": "未知原因"
|
||||
},
|
||||
"orderDetails": "订单详情",
|
||||
"noData": "暂无已过滤订单",
|
||||
"noFilteredOrders": "暂无已过滤订单"
|
||||
},
|
||||
@@ -1350,11 +1382,21 @@
|
||||
"fixedAmountRequired": "请输入固定金额",
|
||||
"fixedAmountInvalid": "固定金额必须大于 0",
|
||||
"priceFilters": "价格过滤",
|
||||
"priceRange": "价格区间",
|
||||
"priceRangeTooltip": "仅跟单 Leader 交易价格在指定区间内的订单。不填写表示不限制。示例:填写 0.11 和 0.89 表示仅跟单价格在 0.11 到 0.89 之间的订单;只填写最高价 0.89 表示仅跟单价格在 0.89 以下的订单;只填写最低价 0.11 表示仅跟单价格在 0.11 以上的订单。",
|
||||
"minPricePlaceholder": "最低价(留空不限制)",
|
||||
"maxPricePlaceholder": "最高价(留空不限制)",
|
||||
"keywordsPlaceholder": "请输入关键字,按回车添加",
|
||||
"maxPositionValuePlaceholder": "留空表示不启用最大仓位限制",
|
||||
"delaySecondsHint": "延迟执行模拟真实跟单延迟",
|
||||
"supportSellHint": "是否跟随 Leader 卖出",
|
||||
"sortBy": "排序字段",
|
||||
"sortOrder": "排序顺序",
|
||||
"createdAt": "创建时间"
|
||||
"createdAt": "创建时间",
|
||||
"rerun": "重新测试",
|
||||
"rerunConfirm": "将按当前配置创建新的回测任务,是否继续?",
|
||||
"rerunTaskNamePlaceholder": "新任务名称(留空使用「原名称 (副本)」)",
|
||||
"rerunSuccess": "已创建新回测任务",
|
||||
"rerunFailed": "重新测试失败"
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@
|
||||
"submit": "提交",
|
||||
"reset": "重置",
|
||||
"close": "關閉",
|
||||
"create": "創建",
|
||||
"copy": "複製",
|
||||
"copySuccess": "複製成功",
|
||||
"copyFailed": "複製失敗",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"actions": "操作",
|
||||
@@ -49,6 +53,7 @@
|
||||
"accountId": "賬戶ID",
|
||||
"accountName": "賬戶名稱",
|
||||
"walletAddress": "錢包地址",
|
||||
"walletType": "錢包類型",
|
||||
"balance": "賬戶餘額",
|
||||
"refreshBalance": "刷新餘額",
|
||||
"apiCredentials": "API 憑證配置",
|
||||
@@ -118,6 +123,7 @@
|
||||
"importAccount": "導入賬戶",
|
||||
"accountName": "賬戶名稱",
|
||||
"walletAddress": "錢包地址",
|
||||
"walletType": "錢包類型",
|
||||
"proxyAddress": "代理錢包地址",
|
||||
"apiCredentials": "API 憑證",
|
||||
"balance": "餘額",
|
||||
@@ -174,8 +180,6 @@
|
||||
"accountImport": {
|
||||
"title": "導入賬戶",
|
||||
"back": "返回",
|
||||
"securityTip": "安全提示",
|
||||
"securityTipDesc": "私鑰將存儲在後端數據庫中,請確保數據庫訪問安全。建議使用 HTTPS 連接。",
|
||||
"importMethod": "導入方式",
|
||||
"privateKey": "私鑰",
|
||||
"mnemonic": "助記詞",
|
||||
@@ -209,9 +213,29 @@
|
||||
"addressErrorMnemonic": "無法從助記詞推導地址",
|
||||
"walletType": "錢包類型",
|
||||
"walletTypeHelp": "Web3錢包:使用 MetaMask 等瀏覽器錢包連接的 Polymarket 帳戶\nMagic:透過郵箱或社群帳號(如 Google、Twitter)登入的 Polymarket 帳戶",
|
||||
"walletTypeMagic": "Magic(郵箱/社群帳號登入)",
|
||||
"walletTypeSafe": "Web3錢包",
|
||||
"magicNotSupported": "(暫不支持)"
|
||||
"loadingProxyOptions": "正在獲取代理地址和資產信息...",
|
||||
"selectProxyOption": "請選擇帳戶類型",
|
||||
"proxyOptionRequired": "請選擇一個代理地址",
|
||||
"proxyOption": {
|
||||
"magic": {
|
||||
"description": "郵箱/社群帳號登入帳戶(Magic)",
|
||||
"title": "Magic 代理地址"
|
||||
},
|
||||
"safe": {
|
||||
"description": "MetaMask 等瀏覽器錢包帳戶(Safe)",
|
||||
"title": "Safe 代理地址"
|
||||
},
|
||||
"proxyAddress": "代理地址",
|
||||
"proxyAddressHelp": "代理地址是您在 Polymarket 上實際使用的錢包地址:Magic 為郵箱/社群登入帳戶,Safe 為瀏覽器錢包(如 MetaMask)帳戶。請選擇與您 Polymarket 登入方式一致的一項。",
|
||||
"availableBalance": "可用餘額",
|
||||
"positionBalance": "倉位餘額",
|
||||
"totalBalance": "總餘額",
|
||||
"positionCount": "持倉數量",
|
||||
"noAssets": "無資產",
|
||||
"hasAssets": "有資產",
|
||||
"error": "獲取失敗",
|
||||
"select": "選擇此代理地址"
|
||||
}
|
||||
},
|
||||
"leader": {
|
||||
"title": "Leader 管理",
|
||||
@@ -430,14 +454,20 @@
|
||||
"openWebsite": "打開網頁",
|
||||
"all": "全部",
|
||||
"copyTradingCount": "跟單數",
|
||||
"backtestCount": "回測數",
|
||||
"copyTradingRelations": "{{count}} 個跟單關係",
|
||||
"viewCopyTradings": "查看跟單配置",
|
||||
"viewBacktests": "查看回測",
|
||||
"createdAt": "創建時間",
|
||||
"noData": "暫無 Leader 數據",
|
||||
"fetchFailed": "獲取 Leader 列表失敗",
|
||||
"deleteSuccess": "刪除 Leader 成功",
|
||||
"deleteFailed": "刪除 Leader 失敗",
|
||||
"deleteConfirm": "確定要刪除這個 Leader 嗎?",
|
||||
"deleteConfirmDesc": "該 Leader 還有 {{count}} 個跟單關係,請先刪除跟單關係"
|
||||
"deleteConfirmDesc": "該 Leader 還有 {{count}} 個跟單關係,請先刪除跟單關係",
|
||||
"openDetailFailed": "打開詳情失敗",
|
||||
"noCopyTradings": "暫無跟單配置",
|
||||
"noBacktests": "暫無回測"
|
||||
},
|
||||
"leaderAdd": {
|
||||
"title": "添加 Leader",
|
||||
@@ -510,6 +540,7 @@
|
||||
"totalBalance": "總餘額",
|
||||
"positions": "持倉列表",
|
||||
"noPositions": "暫無持倉",
|
||||
"market": "市場",
|
||||
"marketId": "市場ID",
|
||||
"side": "方向",
|
||||
"quantity": "數量",
|
||||
@@ -968,8 +999,10 @@
|
||||
"priceRange": "價格區間不符",
|
||||
"maxPositionValue": "超過最大倉位金額",
|
||||
"marketEndDate": "市場截止時間超出限制",
|
||||
"keywordFilter": "關鍵字過濾",
|
||||
"unknown": "未知原因"
|
||||
},
|
||||
"orderDetails": "訂單詳情",
|
||||
"noData": "暫無已過濾訂單",
|
||||
"noFilteredOrders": "暫無已過濾訂單"
|
||||
},
|
||||
@@ -1350,11 +1383,21 @@
|
||||
"fixedAmountRequired": "請輸入固定金額",
|
||||
"fixedAmountInvalid": "固定金額必須大於 0",
|
||||
"priceFilters": "價格過濾",
|
||||
"priceRange": "價格區間",
|
||||
"priceRangeTooltip": "僅跟單 Leader 交易價格在指定區間內的訂單。不填寫表示不限制。示例:填寫 0.11 和 0.89 表示僅跟單價格在 0.11 到 0.89 之間的訂單;只填寫最高價 0.89 表示僅跟單價格在 0.89 以下的訂單;只填寫最低價 0.11 表示僅跟單價格在 0.11 以上的訂單。",
|
||||
"minPricePlaceholder": "最低價(留空不限制)",
|
||||
"maxPricePlaceholder": "最高價(留空不限制)",
|
||||
"keywordsPlaceholder": "請輸入關鍵字,按回車添加",
|
||||
"maxPositionValuePlaceholder": "留空表示不啟用最大倉位限制",
|
||||
"delaySecondsHint": "延遲執行模擬真實跟單延遲",
|
||||
"supportSellHint": "是否跟隨 Leader 賣出",
|
||||
"sortBy": "排序欄位",
|
||||
"sortOrder": "排序順序",
|
||||
"createdAt": "創建時間"
|
||||
"createdAt": "創建時間",
|
||||
"rerun": "重新測試",
|
||||
"rerunConfirm": "將按當前配置創建新的回測任務,是否繼續?",
|
||||
"rerunTaskNamePlaceholder": "新任務名稱(留空使用「原名稱 (副本)」)",
|
||||
"rerunSuccess": "已創建新回測任務",
|
||||
"rerunFailed": "重新測試失敗"
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,13 @@ const AccountDetail: React.FC = () => {
|
||||
<Descriptions.Item label={t('account.accountName')}>
|
||||
{account.accountName || '-'}
|
||||
</Descriptions.Item>
|
||||
{account.walletType && (
|
||||
<Descriptions.Item label={t('account.walletType')}>
|
||||
<Tag color={account.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'}>
|
||||
{account.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label={t('account.walletAddress')} span={isMobile ? 1 : 2}>
|
||||
<span style={{
|
||||
fontFamily: 'monospace',
|
||||
|
||||
@@ -35,8 +35,6 @@ const AccountImport: React.FC = () => {
|
||||
form={form}
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={() => navigate('/accounts')}
|
||||
showAlert={true}
|
||||
showCancelButton={true}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -303,6 +303,20 @@ const AccountList: React.FC = () => {
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('accountList.walletType'),
|
||||
dataIndex: 'walletType',
|
||||
key: 'walletType',
|
||||
render: (walletType: string) => {
|
||||
if (!walletType) return '-'
|
||||
const type = walletType.toLowerCase()
|
||||
return (
|
||||
<Tag color={type === 'magic' ? 'purple' : 'blue'}>
|
||||
{type === 'magic' ? 'Magic' : 'Safe'}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('accountList.balance'),
|
||||
dataIndex: 'balance',
|
||||
@@ -392,7 +406,7 @@ const AccountList: React.FC = () => {
|
||||
style={{ marginLeft: '4px', padding: '0 4px' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: '4px' }}>
|
||||
<strong>{t('accountList.proxyAddress')}:</strong> {record.proxyAddress ? `${record.proxyAddress.slice(0, 6)}...${record.proxyAddress.slice(-4)}` : '-'}
|
||||
<Button
|
||||
type="text"
|
||||
@@ -405,6 +419,14 @@ const AccountList: React.FC = () => {
|
||||
style={{ marginLeft: '4px', padding: '0 4px' }}
|
||||
/>
|
||||
</div>
|
||||
{record.walletType && (
|
||||
<div style={{ marginBottom: '4px' }}>
|
||||
<strong>{t('accountList.walletType')}:</strong>{' '}
|
||||
<Tag color={record.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'} style={{ marginLeft: '4px' }}>
|
||||
{record.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
@@ -654,6 +676,13 @@ const AccountList: React.FC = () => {
|
||||
/>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
{detailAccount.walletType && (
|
||||
<Descriptions.Item label={t('accountList.walletType')}>
|
||||
<Tag color={detailAccount.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'}>
|
||||
{detailAccount.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label={t('accountList.totalBalance')} span={isMobile ? 1 : 2}>
|
||||
{detailBalanceLoading ? (
|
||||
<Spin size="small" />
|
||||
@@ -854,9 +883,9 @@ const AccountList: React.FC = () => {
|
||||
accountImportForm.resetFields()
|
||||
}}
|
||||
footer={null}
|
||||
width={isMobile ? '95%' : 600}
|
||||
width={isMobile ? '95%' : 640}
|
||||
style={{ top: isMobile ? 20 : 50 }}
|
||||
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 150px)', overflow: 'auto' }}
|
||||
bodyStyle={{ padding: isMobile ? '16px 20px' : '24px 28px', maxHeight: 'calc(100vh - 140px)', overflow: 'auto' }}
|
||||
destroyOnClose
|
||||
maskClosable
|
||||
closable
|
||||
@@ -868,8 +897,6 @@ const AccountList: React.FC = () => {
|
||||
setAccountImportModalVisible(false)
|
||||
accountImportForm.resetFields()
|
||||
}}
|
||||
showAlert={true}
|
||||
showCancelButton={true}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Table, Card, Button, Select, Tag, Space, Modal, message, Row, Col, Form, Input, InputNumber, Switch, Statistic, Descriptions } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PlusOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, EyeOutlined, RedoOutlined, CopyOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, EyeOutlined, RedoOutlined, CopyOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import { formatUSDC } from '../utils'
|
||||
import { backtestService, apiService } from '../services/api'
|
||||
import type { BacktestTaskDto, BacktestListRequest, BacktestCreateRequest, BacktestTradeDto } from '../types/backtest'
|
||||
@@ -9,11 +10,11 @@ import type { Leader } from '../types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import AddCopyTradingModal from './CopyTradingOrders/AddModal'
|
||||
import BacktestChart from './BacktestChart'
|
||||
|
||||
const { Option } = Select
|
||||
import LeaderSelect from '../components/LeaderSelect'
|
||||
|
||||
const BacktestList: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [tasks, setTasks] = useState<BacktestTaskDto[]>([])
|
||||
@@ -21,7 +22,14 @@ const BacktestList: React.FC = () => {
|
||||
const [page, setPage] = useState(1)
|
||||
const [size] = useState(10)
|
||||
const [statusFilter, setStatusFilter] = useState<string | undefined>()
|
||||
const [leaderIdFilter] = useState<number | undefined>()
|
||||
const [leaderIdFilter, setLeaderIdFilter] = useState<number | undefined>(() => {
|
||||
const leaderIdParam = searchParams.get('leaderId')
|
||||
if (leaderIdParam) {
|
||||
const id = parseInt(leaderIdParam, 10)
|
||||
return isNaN(id) ? undefined : id
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
const [sortBy, setSortBy] = useState<'profitAmount' | 'profitRate' | 'createdAt'>('createdAt')
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
|
||||
|
||||
@@ -36,6 +44,12 @@ const BacktestList: React.FC = () => {
|
||||
const [addCopyTradingModalVisible, setAddCopyTradingModalVisible] = useState(false)
|
||||
const [preFilledConfig, setPreFilledConfig] = useState<any>(null)
|
||||
|
||||
// 重新测试 Modal 相关状态
|
||||
const [rerunModalVisible, setRerunModalVisible] = useState(false)
|
||||
const [rerunTask, setRerunTask] = useState<BacktestTaskDto | null>(null)
|
||||
const [rerunTaskName, setRerunTaskName] = useState('')
|
||||
const [rerunLoading, setRerunLoading] = useState(false)
|
||||
|
||||
// 任务详情 Modal 相关状态
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false)
|
||||
const [detailTask, setDetailTask] = useState<BacktestTaskDto | null>(null)
|
||||
@@ -48,9 +62,9 @@ const BacktestList: React.FC = () => {
|
||||
const [detailTradesPage, setDetailTradesPage] = useState(1)
|
||||
const [detailTradesSize] = useState(20)
|
||||
|
||||
// 获取回测任务列表
|
||||
const fetchTasks = async () => {
|
||||
setLoading(true)
|
||||
// 获取回测任务列表(silent 为 true 时不显示 loading,用于轮询刷新)
|
||||
const fetchTasks = async (silent = false) => {
|
||||
if (!silent) setLoading(true)
|
||||
try {
|
||||
const request: BacktestListRequest = {
|
||||
leaderId: leaderIdFilter,
|
||||
@@ -64,21 +78,40 @@ const BacktestList: React.FC = () => {
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setTasks(response.data.data.list)
|
||||
setTotal(response.data.data.total)
|
||||
} else {
|
||||
} else if (!silent) {
|
||||
message.error(response.data.msg || t('backtest.fetchTasksFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch backtest tasks:', error)
|
||||
message.error(t('backtest.fetchTasksFailed'))
|
||||
if (!silent) message.error(t('backtest.fetchTasksFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 从 URL 读取 leaderId 并应用筛选(如从 Leader 管理页跳转过来)
|
||||
useEffect(() => {
|
||||
const leaderIdParam = searchParams.get('leaderId')
|
||||
if (leaderIdParam) {
|
||||
const id = parseInt(leaderIdParam, 10)
|
||||
setLeaderIdFilter(isNaN(id) ? undefined : id)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks()
|
||||
}, [page, statusFilter, leaderIdFilter, sortBy, sortOrder])
|
||||
|
||||
// 存在非终态任务(PENDING/RUNNING)时每 3s 轮询刷新进度
|
||||
const hasNonTerminalTask = tasks.some(
|
||||
(task) => task.status === 'PENDING' || task.status === 'RUNNING'
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!hasNonTerminalTask) return
|
||||
const timer = setInterval(() => fetchTasks(true), 3000)
|
||||
return () => clearInterval(timer)
|
||||
}, [hasNonTerminalTask, page, statusFilter, leaderIdFilter, sortBy, sortOrder])
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
fetchTasks()
|
||||
@@ -130,6 +163,38 @@ const BacktestList: React.FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 按配置重新测试(仅已完成任务)
|
||||
const handleRerun = (task: BacktestTaskDto) => {
|
||||
setRerunTask(task)
|
||||
setRerunTaskName(`${task.taskName} (副本)`)
|
||||
setRerunModalVisible(true)
|
||||
}
|
||||
|
||||
const handleRerunSubmit = async () => {
|
||||
if (!rerunTask) return
|
||||
setRerunLoading(true)
|
||||
try {
|
||||
const response = await backtestService.rerun({
|
||||
id: rerunTask.id,
|
||||
taskName: rerunTaskName.trim() || undefined
|
||||
})
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('backtest.rerunSuccess'))
|
||||
setRerunModalVisible(false)
|
||||
setRerunTask(null)
|
||||
setRerunTaskName('')
|
||||
fetchTasks()
|
||||
} else {
|
||||
message.error(response.data.msg || t('backtest.rerunFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Rerun backtest failed:', error)
|
||||
message.error(t('backtest.rerunFailed'))
|
||||
} finally {
|
||||
setRerunLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 重试任务
|
||||
const handleRetry = (id: number) => {
|
||||
Modal.confirm({
|
||||
@@ -153,22 +218,20 @@ const BacktestList: React.FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 获取 Leader 列表
|
||||
// 获取 Leader 列表(页面加载时请求,供筛选和创建任务使用)
|
||||
useEffect(() => {
|
||||
if (createModalVisible) {
|
||||
const fetchLeaders = async () => {
|
||||
try {
|
||||
const response = await apiService.leaders.list({})
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setLeaders(response.data.data.list || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch leaders:', error)
|
||||
const fetchLeaders = async () => {
|
||||
try {
|
||||
const response = await apiService.leaders.list({})
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setLeaders(response.data.data.list || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch leaders:', error)
|
||||
}
|
||||
fetchLeaders()
|
||||
}
|
||||
}, [createModalVisible])
|
||||
fetchLeaders()
|
||||
}, [])
|
||||
|
||||
// 打开创建 modal
|
||||
const handleCreate = () => {
|
||||
@@ -208,7 +271,10 @@ const BacktestList: React.FC = () => {
|
||||
maxDailyOrders: values.maxDailyOrders,
|
||||
supportSell: values.supportSell,
|
||||
keywordFilterMode: values.keywordFilterMode,
|
||||
keywords: values.keywords
|
||||
keywords: values.keywords,
|
||||
maxPositionValue: values.maxPositionValue,
|
||||
minPrice: values.minPrice,
|
||||
maxPrice: values.maxPrice
|
||||
}
|
||||
|
||||
const response = await backtestService.create(request)
|
||||
@@ -259,6 +325,9 @@ const BacktestList: React.FC = () => {
|
||||
supportSell: taskConfig.supportSell,
|
||||
keywordFilterMode: taskConfig.keywordFilterMode || 'DISABLED',
|
||||
keywords: taskConfig.keywords || [],
|
||||
maxPositionValue: taskConfig.maxPositionValue,
|
||||
minPrice: taskConfig.minPrice,
|
||||
maxPrice: taskConfig.maxPrice,
|
||||
configName: `回测任务-${taskDetail.taskName}`
|
||||
}
|
||||
|
||||
@@ -440,7 +509,7 @@ const BacktestList: React.FC = () => {
|
||||
dataIndex: 'leaderName',
|
||||
key: 'leaderName',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (_: any, record: BacktestTaskDto) => record.leaderName || record.leaderAddress?.substring(0, 8) + '...' || '-'
|
||||
render: (_: any, record: BacktestTaskDto) => record.leaderName || `Leader ${record.leaderId}`
|
||||
},
|
||||
{
|
||||
title: t('backtest.initialBalance'),
|
||||
@@ -545,14 +614,24 @@ const BacktestList: React.FC = () => {
|
||||
{t('common.viewDetail')}
|
||||
</Button>
|
||||
{record.status === 'COMPLETED' && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => handleCreateCopyTrading(record)}
|
||||
>
|
||||
{t('backtest.createCopyTrading')}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={() => handleRerun(record)}
|
||||
>
|
||||
{t('backtest.rerun')}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => handleCreateCopyTrading(record)}
|
||||
>
|
||||
{t('backtest.createCopyTrading')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{record.status === 'RUNNING' && (
|
||||
<Button
|
||||
@@ -575,7 +654,7 @@ const BacktestList: React.FC = () => {
|
||||
{t('backtest.retry')}
|
||||
</Button>
|
||||
)}
|
||||
{(record.status === 'PENDING' || record.status === 'COMPLETED') && (
|
||||
{(record.status === 'PENDING' || record.status === 'COMPLETED' || record.status === 'STOPPED' || record.status === 'FAILED') && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -599,6 +678,14 @@ const BacktestList: React.FC = () => {
|
||||
<Row justify="space-between" align="middle" gutter={[16, 16]}>
|
||||
<Col xs={24} sm={24} md={12} lg={16}>
|
||||
<Space size="middle" direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
|
||||
<LeaderSelect
|
||||
style={{ width: isMobile ? '100%' : 180 }}
|
||||
placeholder={t('backtest.leader')}
|
||||
allowClear
|
||||
value={leaderIdFilter}
|
||||
onChange={(value) => setLeaderIdFilter(value)}
|
||||
leaders={leaders}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: isMobile ? '100%' : 150 }}
|
||||
placeholder={t('backtest.status')}
|
||||
@@ -675,6 +762,30 @@ const BacktestList: React.FC = () => {
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 重新测试 Modal */}
|
||||
<Modal
|
||||
title={t('backtest.rerun')}
|
||||
open={rerunModalVisible}
|
||||
onCancel={() => {
|
||||
setRerunModalVisible(false)
|
||||
setRerunTask(null)
|
||||
setRerunTaskName('')
|
||||
}}
|
||||
onOk={handleRerunSubmit}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
confirmLoading={rerunLoading}
|
||||
destroyOnClose
|
||||
>
|
||||
<p style={{ marginBottom: 8 }}>{t('backtest.rerunConfirm')}</p>
|
||||
<Input
|
||||
value={rerunTaskName}
|
||||
onChange={(e) => setRerunTaskName(e.target.value)}
|
||||
placeholder={t('backtest.rerunTaskNamePlaceholder')}
|
||||
maxLength={100}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* 创建回测任务 Modal */}
|
||||
<Modal
|
||||
title={t('backtest.createTask')}
|
||||
@@ -697,7 +808,7 @@ const BacktestList: React.FC = () => {
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
maxDailyLoss: 500,
|
||||
maxDailyOrders: 50,
|
||||
maxDailyOrders: 100,
|
||||
supportSell: true,
|
||||
keywordFilterMode: 'DISABLED',
|
||||
backtestDays: 7
|
||||
@@ -719,13 +830,10 @@ const BacktestList: React.FC = () => {
|
||||
name="leaderId"
|
||||
rules={[{ required: true, message: t('backtest.leaderRequired') || '请选择 Leader' }]}
|
||||
>
|
||||
<Select placeholder={t('backtest.leader')} showSearch>
|
||||
{leaders.map((leader) => (
|
||||
<Option key={leader.id} value={leader.id}>
|
||||
{leader.leaderName || leader.leaderAddress}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<LeaderSelect
|
||||
leaders={leaders}
|
||||
placeholder={t('backtest.leader')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -777,8 +885,8 @@ const BacktestList: React.FC = () => {
|
||||
name="copyMode"
|
||||
>
|
||||
<Select onChange={(value) => setCopyMode(value)}>
|
||||
<Option value="RATIO">{t('backtest.copyModeRatio')}</Option>
|
||||
<Option value="FIXED">{t('backtest.copyModeFixed')}</Option>
|
||||
<Select.Option value="RATIO">{t('backtest.copyModeRatio')}</Select.Option>
|
||||
<Select.Option value="FIXED">{t('backtest.copyModeFixed')}</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
@@ -876,6 +984,62 @@ const BacktestList: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.maxPositionValue') + ' (USDC)'}
|
||||
name="maxPositionValue"
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('backtest.maxPositionValuePlaceholder') || '留空表示不启用最大仓位限制'}
|
||||
precision={2}
|
||||
min={0}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.priceRange')}
|
||||
tooltip={t('backtest.priceRangeTooltip')}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="minPrice" noStyle>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('backtest.minPricePlaceholder') || '最低价(留空不限制)'}
|
||||
min={0.01}
|
||||
max={0.99}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="maxPrice" noStyle>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('backtest.maxPricePlaceholder') || '最高价(留空不限制)'}
|
||||
min={0.01}
|
||||
max={0.99}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.supportSell')}
|
||||
name="supportSell"
|
||||
@@ -890,9 +1054,9 @@ const BacktestList: React.FC = () => {
|
||||
name="keywordFilterMode"
|
||||
>
|
||||
<Select>
|
||||
<Option value="DISABLED">{t('backtest.keywordFilterModeDisabled')}</Option>
|
||||
<Option value="WHITELIST">{t('backtest.keywordFilterModeWhitelist')}</Option>
|
||||
<Option value="BLACKLIST">{t('backtest.keywordFilterModeBlacklist')}</Option>
|
||||
<Select.Option value="DISABLED">{t('backtest.keywordFilterModeDisabled')}</Select.Option>
|
||||
<Select.Option value="WHITELIST">{t('backtest.keywordFilterModeWhitelist')}</Select.Option>
|
||||
<Select.Option value="BLACKLIST">{t('backtest.keywordFilterModeBlacklist')}</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
@@ -942,6 +1106,12 @@ const BacktestList: React.FC = () => {
|
||||
footer={
|
||||
detailTask && detailTask.status === 'COMPLETED' && detailConfig ? (
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button icon={<SyncOutlined />} onClick={() => {
|
||||
setDetailModalVisible(false)
|
||||
handleRerun(detailTask)
|
||||
}}>
|
||||
{t('backtest.rerun')}
|
||||
</Button>
|
||||
<Button type="primary" icon={<CopyOutlined />} onClick={() => {
|
||||
const preFilled = {
|
||||
leaderId: detailTask.leaderId,
|
||||
@@ -955,6 +1125,9 @@ const BacktestList: React.FC = () => {
|
||||
supportSell: detailConfig.supportSell,
|
||||
keywordFilterMode: detailConfig.keywordFilterMode,
|
||||
keywords: detailConfig.keywords || [],
|
||||
maxPositionValue: detailConfig.maxPositionValue,
|
||||
minPrice: detailConfig.minPrice,
|
||||
maxPrice: detailConfig.maxPrice,
|
||||
configName: `回测任务-${detailTask.taskName}`
|
||||
}
|
||||
setPreFilledConfig(preFilled)
|
||||
@@ -984,7 +1157,7 @@ const BacktestList: React.FC = () => {
|
||||
<Descriptions column={isMobile ? 1 : 2} bordered size="small">
|
||||
<Descriptions.Item label={t('backtest.taskName')}>{detailTask.taskName}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('backtest.leader')}>
|
||||
{detailTask.leaderName || detailTask.leaderAddress}
|
||||
{detailTask.leaderName || `Leader ${detailTask.leaderId}`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('backtest.initialBalance')}>
|
||||
{formatUSDC(detailTask.initialBalance)} USDC
|
||||
@@ -1140,6 +1313,25 @@ const BacktestList: React.FC = () => {
|
||||
{detailConfig.keywords.join(', ')}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{detailConfig.maxPositionValue && (
|
||||
<Descriptions.Item label={t('backtest.maxPositionValue')}>
|
||||
{formatUSDC(detailConfig.maxPositionValue)} USDC
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{(detailConfig.minPrice || detailConfig.maxPrice) && (
|
||||
<Descriptions.Item label={t('backtest.priceRange')}>
|
||||
{detailConfig.minPrice !== undefined && detailConfig.minPrice !== null && detailConfig.minPrice !== ''
|
||||
? `≥ ${parseFloat(detailConfig.minPrice).toFixed(4)}`
|
||||
: ''}
|
||||
{(detailConfig.minPrice !== undefined && detailConfig.minPrice !== null && detailConfig.minPrice !== '') &&
|
||||
(detailConfig.maxPrice !== undefined && detailConfig.maxPrice !== null && detailConfig.maxPrice !== '')
|
||||
? ' ~ '
|
||||
: ''}
|
||||
{detailConfig.maxPrice !== undefined && detailConfig.maxPrice !== null && detailConfig.maxPrice !== ''
|
||||
? `≤ ${parseFloat(detailConfig.maxPrice).toFixed(4)}`
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Dropdown, Divider, Spin } from 'antd'
|
||||
import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -13,11 +14,13 @@ import StatisticsModal from './CopyTradingOrders/StatisticsModal'
|
||||
import FilteredOrdersModal from './CopyTradingOrders/FilteredOrdersModal'
|
||||
import EditModal from './CopyTradingOrders/EditModal'
|
||||
import AddModal from './CopyTradingOrders/AddModal'
|
||||
import LeaderSelect from '../components/LeaderSelect'
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
const CopyTradingList: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { accounts, fetchAccounts } = useAccountStore()
|
||||
const [copyTradings, setCopyTradings] = useState<CopyTrading[]>([])
|
||||
@@ -29,8 +32,15 @@ const CopyTradingList: React.FC = () => {
|
||||
accountId?: number
|
||||
leaderId?: number
|
||||
enabled?: boolean
|
||||
}>({})
|
||||
|
||||
}>(() => {
|
||||
const leaderIdParam = searchParams.get('leaderId')
|
||||
if (leaderIdParam) {
|
||||
const leaderId = parseInt(leaderIdParam, 10)
|
||||
if (!isNaN(leaderId)) return { leaderId }
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
// Modal 状态
|
||||
const [ordersModalOpen, setOrdersModalOpen] = useState(false)
|
||||
const [ordersModalCopyTradingId, setOrdersModalCopyTradingId] = useState<string>('')
|
||||
@@ -43,12 +53,23 @@ const CopyTradingList: React.FC = () => {
|
||||
const [editModalCopyTradingId, setEditModalCopyTradingId] = useState<string>('')
|
||||
const [addModalOpen, setAddModalOpen] = useState(false)
|
||||
|
||||
// 从 URL 读取 leaderId 并应用筛选(如从 Leader 管理页跳转过来)
|
||||
useEffect(() => {
|
||||
const leaderIdParam = searchParams.get('leaderId')
|
||||
if (leaderIdParam) {
|
||||
const leaderId = parseInt(leaderIdParam, 10)
|
||||
if (!isNaN(leaderId)) {
|
||||
setFilters(prev => ({ ...prev, leaderId }))
|
||||
}
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
fetchLeaders()
|
||||
fetchCopyTradings()
|
||||
}, [])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchCopyTradings()
|
||||
}, [filters])
|
||||
@@ -390,19 +411,14 @@ const CopyTradingList: React.FC = () => {
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
<LeaderSelect
|
||||
placeholder={t('copyTradingList.filterLeader') || '筛选 Leader'}
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 200 }}
|
||||
value={filters.leaderId}
|
||||
onChange={(value) => setFilters({ ...filters, leaderId: value || undefined })}
|
||||
>
|
||||
{leaders.map(leader => (
|
||||
<Option key={leader.id} value={leader.id}>
|
||||
{leader.leaderName || `Leader ${leader.id}`}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
leaders={leaders}
|
||||
/>
|
||||
|
||||
<Select
|
||||
placeholder="筛选状态"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import AccountImportForm from '../../components/AccountImportForm'
|
||||
import LeaderAddForm from '../../components/LeaderAddForm'
|
||||
import LeaderSelect from '../../components/LeaderSelect'
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
@@ -28,6 +29,7 @@ interface AddModalProps {
|
||||
supportSell?: boolean
|
||||
keywordFilterMode?: string
|
||||
keywords?: string[]
|
||||
maxPositionValue?: number
|
||||
configName?: string
|
||||
}
|
||||
}
|
||||
@@ -124,7 +126,8 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
maxDailyLoss: config.maxDailyLoss,
|
||||
maxDailyOrders: config.maxDailyOrders,
|
||||
supportSell: config.supportSell,
|
||||
keywordFilterMode: config.keywordFilterMode || 'DISABLED'
|
||||
keywordFilterMode: config.keywordFilterMode || 'DISABLED',
|
||||
maxPositionValue: config.maxPositionValue
|
||||
}
|
||||
console.log('[AddModal] fillPreFilledConfig: setting form values:', formValues)
|
||||
|
||||
@@ -467,8 +470,10 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
name="leaderId"
|
||||
rules={[{ required: true, message: t('copyTradingAdd.leaderRequired') || '请选择 Leader' }]}
|
||||
>
|
||||
<Select
|
||||
<LeaderSelect
|
||||
leaders={leaders}
|
||||
placeholder={t('copyTradingAdd.selectLeaderPlaceholder') || '请选择 Leader'}
|
||||
onSelectChange={(value) => value !== undefined && fetchLeaderAssetInfo(value)}
|
||||
notFoundContent={
|
||||
leaders.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '12px' }}>
|
||||
@@ -484,14 +489,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
onChange={(value) => fetchLeaderAssetInfo(value)}
|
||||
>
|
||||
{leaders.map(leader => (
|
||||
<Option key={leader.id} value={leader.id}>
|
||||
{leader.leaderName || `Leader ${leader.id}`} ({leader.leaderAddress.slice(0, 6)}...{leader.leaderAddress.slice(-4)})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Leader 资产信息 */}
|
||||
@@ -1108,9 +1106,9 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
accountImportForm.resetFields()
|
||||
}}
|
||||
footer={null}
|
||||
width={isMobile ? '95%' : 600}
|
||||
width={isMobile ? '95%' : 640}
|
||||
style={{ top: isMobile ? 20 : 50 }}
|
||||
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 150px)', overflow: 'auto' }}
|
||||
bodyStyle={{ padding: isMobile ? '16px 20px' : '24px 28px', maxHeight: 'calc(100vh - 140px)', overflow: 'auto' }}
|
||||
destroyOnClose
|
||||
maskClosable
|
||||
closable
|
||||
@@ -1122,8 +1120,6 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
setAccountImportModalVisible(false)
|
||||
accountImportForm.resetFields()
|
||||
}}
|
||||
showAlert={true}
|
||||
showCancelButton={true}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -323,7 +323,10 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
>
|
||||
<Select disabled>
|
||||
<Option value={copyTrading.leaderId}>
|
||||
{copyTrading.leaderName || `Leader ${copyTrading.leaderId}`} ({copyTrading.leaderAddress.slice(0, 6)}...{copyTrading.leaderAddress.slice(-4)})
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span>{copyTrading.leaderName || `Leader ${copyTrading.leaderId}`}</span>
|
||||
<span style={{ fontSize: '12px', color: '#999' }}>{copyTrading.leaderAddress}</span>
|
||||
</div>
|
||||
</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
@@ -260,7 +260,34 @@ const LeaderList: React.FC = () => {
|
||||
dataIndex: 'copyTradingCount',
|
||||
key: 'copyTradingCount',
|
||||
width: 100,
|
||||
render: (count: number) => <Tag color="cyan">{count}</Tag>
|
||||
render: (count: number, record: Leader) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => navigate(`/copy-trading?leaderId=${record.id}`)}
|
||||
disabled={count === 0}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
<Tag color="cyan">{count}</Tag>
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('leaderList.backtestCount'),
|
||||
dataIndex: 'backtestCount',
|
||||
key: 'backtestCount',
|
||||
width: 100,
|
||||
render: (count: number, record: Leader) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => navigate(`/backtest?leaderId=${record.id}`)}
|
||||
disabled={count === 0}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
<Tag color="purple">{count}</Tag>
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('common.actions'),
|
||||
@@ -345,7 +372,24 @@ const LeaderList: React.FC = () => {
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
|
||||
<Tag color="cyan">{leader.copyTradingCount} {t('leaderList.copyTradingCount')}</Tag>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
onClick={() => navigate(`/copy-trading?leaderId=${leader.id}`)}
|
||||
disabled={leader.copyTradingCount === 0}
|
||||
style={{ borderRadius: '6px', padding: '8px 16px' }}
|
||||
>
|
||||
{t('leaderList.viewCopyTradings')} ({leader.copyTradingCount})
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
onClick={() => navigate(`/backtest?leaderId=${leader.id}`)}
|
||||
disabled={leader.backtestCount === 0}
|
||||
style={{ borderRadius: '6px', padding: '8px 16px' }}
|
||||
>
|
||||
{t('leaderList.viewBacktests')} ({leader.backtestCount})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{leader.remark && (
|
||||
@@ -356,15 +400,15 @@ const LeaderList: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<Button type="primary" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(leader)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
|
||||
<Button type="primary" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(leader)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
|
||||
{t('common.viewDetail')}
|
||||
</Button>
|
||||
{leader.website && (
|
||||
<Button type="default" size="small" icon={<GlobalOutlined />} onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
|
||||
<Button type="default" size="small" icon={<GlobalOutlined />} onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
|
||||
{t('leaderList.openWebsite')}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="default" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
|
||||
<Button type="default" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
@@ -374,7 +418,7 @@ const LeaderList: React.FC = () => {
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<Button type="primary" danger size="small" icon={<DeleteOutlined />} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
|
||||
<Button type="primary" danger size="small" icon={<DeleteOutlined />} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
|
||||
@@ -62,10 +62,10 @@ const PositionList: React.FC = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 当仓位数据变化时,更新可赎回统计
|
||||
// 当仓位数据变化时,静默更新可赎回统计(不显示loading状态)
|
||||
useEffect(() => {
|
||||
if (currentPositions.length > 0) {
|
||||
fetchRedeemableSummary()
|
||||
fetchRedeemableSummarySilently()
|
||||
}
|
||||
}, [currentPositions, selectedAccountId])
|
||||
|
||||
@@ -74,7 +74,19 @@ const PositionList: React.FC = () => {
|
||||
setCurrentPage(1)
|
||||
}, [positionFilter, selectedAccountId, searchKeyword])
|
||||
|
||||
// 获取可赎回仓位统计
|
||||
// 静默获取可赎回仓位统计(不显示loading状态)
|
||||
const fetchRedeemableSummarySilently = async () => {
|
||||
try {
|
||||
const response = await apiService.accounts.getRedeemableSummary({ accountId: selectedAccountId })
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setRedeemableSummary(response.data.data)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取可赎回统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取可赎回仓位统计(带loading状态,用于用户主动操作)
|
||||
const fetchRedeemableSummary = async () => {
|
||||
setLoadingRedeemableSummary(true)
|
||||
try {
|
||||
@@ -91,8 +103,9 @@ const PositionList: React.FC = () => {
|
||||
|
||||
// 处理赎回按钮点击
|
||||
const handleRedeemClick = async () => {
|
||||
await fetchRedeemableSummary()
|
||||
setRedeemModalVisible(true)
|
||||
// 打开模态框时重新获取最新数据
|
||||
fetchRedeemableSummary()
|
||||
}
|
||||
|
||||
// 提交赎回
|
||||
@@ -875,6 +888,7 @@ const PositionList: React.FC = () => {
|
||||
: null
|
||||
|
||||
const handleTitleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
|
||||
@@ -206,6 +206,12 @@ export const apiService = {
|
||||
* 账户管理 API
|
||||
*/
|
||||
accounts: {
|
||||
/**
|
||||
* 检查代理地址选项(导入前选择代理类型)
|
||||
*/
|
||||
checkProxyOptions: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/accounts/check-proxy-options', data),
|
||||
|
||||
/**
|
||||
* 导入账户
|
||||
*/
|
||||
@@ -797,6 +803,11 @@ export const backtestService = {
|
||||
/**
|
||||
* 重试回测任务
|
||||
*/
|
||||
retry: (data: { id: number }) => apiClient.post('/backtest/tasks/retry', data)
|
||||
retry: (data: { id: number }) => apiClient.post('/backtest/tasks/retry', data),
|
||||
|
||||
/**
|
||||
* 按当前配置重新测试(仅支持已完成任务,创建同名配置的新任务)
|
||||
*/
|
||||
rerun: (data: { id: number; taskName?: string }) => apiClient.post('/backtest/tasks/rerun', data)
|
||||
}
|
||||
|
||||
|
||||
@@ -60,8 +60,10 @@ export const useAccountStore = create<AccountStore>((set, get) => ({
|
||||
if (response.data.code === 0) {
|
||||
await get().fetchAccounts()
|
||||
} else {
|
||||
const err = new Error(response.data.msg || '导入账户失败')
|
||||
;(err as Error & { code?: number }).code = response.data.code
|
||||
set({ error: response.data.msg || '导入账户失败', loading: false })
|
||||
throw new Error(response.data.msg || '导入账户失败')
|
||||
throw err
|
||||
}
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || '导入账户失败', loading: false })
|
||||
|
||||
@@ -46,6 +46,37 @@ export interface AccountImportRequest {
|
||||
walletType?: string // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理地址选项请求
|
||||
*/
|
||||
export interface CheckProxyOptionsRequest {
|
||||
walletAddress: string // EOA 地址
|
||||
privateKey?: string // 私钥(加密,私钥导入时提供)
|
||||
mnemonic?: string // 助记词(加密,助记词导入时提供)
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理地址选项信息
|
||||
*/
|
||||
export interface ProxyOption {
|
||||
walletType: string // "magic" 或 "safe"
|
||||
proxyAddress: string // 代理地址
|
||||
descriptionKey: string // 说明文案的多语言 key
|
||||
availableBalance: string // 可用余额
|
||||
positionBalance: string // 仓位余额
|
||||
totalBalance: string // 总余额
|
||||
positionCount: number // 持仓数量
|
||||
hasAssets: boolean // 是否有资产
|
||||
error?: string // 获取失败时的错误信息
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理地址选项响应
|
||||
*/
|
||||
export interface CheckProxyOptionsResponse {
|
||||
options: ProxyOption[] // 代理地址选项列表
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户更新请求
|
||||
*/
|
||||
@@ -65,6 +96,7 @@ export interface Leader {
|
||||
remark?: string // Leader 备注(可选)
|
||||
website?: string // Leader 网站(可选)
|
||||
copyTradingCount: number
|
||||
backtestCount: number // 回测数量
|
||||
totalOrders?: number
|
||||
totalPnl?: string
|
||||
createdAt: number
|
||||
@@ -978,3 +1010,27 @@ export interface NodeCheckResult {
|
||||
responseTimeMs?: number
|
||||
blockNumber?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 回测任务 DTO
|
||||
*/
|
||||
export interface BacktestTaskDto {
|
||||
id: number
|
||||
taskName: string
|
||||
leaderId: number
|
||||
leaderName?: string
|
||||
leaderAddress?: string
|
||||
initialBalance: string
|
||||
finalBalance?: string
|
||||
profitAmount?: string
|
||||
profitRate?: string
|
||||
backtestDays: number
|
||||
startTime: number
|
||||
endTime?: number
|
||||
status: string // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
|
||||
progress: number
|
||||
totalTrades: number
|
||||
createdAt: number
|
||||
executionStartedAt?: number
|
||||
executionFinishedAt?: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user