Compare commits
87 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 | |||
| 8f52b5016a | |||
| 5c0808c2cb | |||
| 9d01c120e5 | |||
| ec8cfeac77 | |||
| 7c833f1e9b | |||
| 08cc30a90f | |||
| 8288cd7579 | |||
| 55da551971 | |||
| ff3b24b50e | |||
| 88cae4018a | |||
| 96fbc3f720 | |||
| fd25821e39 | |||
| cdd02e9f3d | |||
| fabbd81f22 | |||
| ace32b37cf | |||
| f1eae8a40b | |||
| ca655f351d | |||
| 40081c2464 | |||
| f1f809f54b | |||
| e8fd1b503b | |||
| 390b3ee876 | |||
| 80976609c7 | |||
| 17eea0183f | |||
| 6980781f89 | |||
| 3350039f05 | |||
| 0bdc0c74d1 | |||
| cccc829cef | |||
| fa1a915e9c | |||
| e5992b5145 | |||
| 42472f6b03 | |||
| c56e08e681 | |||
| 7e87965418 | |||
| e115d459f7 | |||
| dd39e59304 | |||
| 2efc04a3c3 | |||
| 2b20f4b2e2 | |||
| 419c68c024 | |||
| 8889803dd7 | |||
| 9c5517768f | |||
| 4aa85a9c2d | |||
| a77b3b10ee | |||
| 532f4c3e25 | |||
| 04629e73b6 | |||
| 696193c571 | |||
| 38e256c4fb | |||
| 64a78406ed | |||
| 59297ec52f | |||
| 0ff6832dc1 | |||
| 56a8928631 | |||
| 266c6d595d | |||
| b2e0816968 | |||
| dc47583565 | |||
| 26dd3bb387 | |||
| fabfe601c6 | |||
| 662aa47de6 | |||
| c3d9d10d5d | |||
| 9926533049 | |||
| 4d72017b97 | |||
| 45734c051e | |||
| db8471bb16 | |||
| 0dc6f5894f | |||
| c9769aa17a | |||
| 07b4d654b4 | |||
| b65827038f | |||
| d768da72c6 | |||
| 7385efff1a |
@@ -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"]
|
||||
}
|
||||
|
||||
+2
-2
@@ -14,11 +14,11 @@
|
||||
.DS_Store
|
||||
|
||||
# 构建产物
|
||||
backend/build/
|
||||
# 注意:frontend/dist 和 backend/build/libs 在使用 BUILD_IN_DOCKER=false 时是必需的
|
||||
# 所以不能忽略它们。在 BUILD_IN_DOCKER=true 时,它们会被 Docker 内部编译覆盖
|
||||
backend/.gradle/
|
||||
backend/out/
|
||||
backend/bin/
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
frontend/.vite/
|
||||
frontend/.cache/
|
||||
|
||||
@@ -4,39 +4,104 @@ on:
|
||||
release:
|
||||
types:
|
||||
- published # 当通过 GitHub Releases 页面创建 release 时触发
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_type:
|
||||
description: '构建类型'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- package-only # 只打包产物
|
||||
- package-and-docker # 打包产物 + Docker 镜像
|
||||
default: 'package-and-docker'
|
||||
version:
|
||||
description: '版本号(例如: v1.0.0)'
|
||||
required: false
|
||||
type: string
|
||||
tag_name:
|
||||
description: 'Git Tag 名称(留空则使用 version)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write # 需要写权限以上传 Assets
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }} # 使用 release 对应的 tag
|
||||
ref: ${{ github.event.release.tag_name || github.event.inputs.tag_name || github.event.inputs.version || github.ref }}
|
||||
|
||||
- name: Extract version from release
|
||||
- name: Determine build type
|
||||
id: build_config
|
||||
run: |
|
||||
# 确定构建类型
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
# Release 事件:默认只打包产物(不构建 Docker)
|
||||
BUILD_TYPE="package-only"
|
||||
echo "📦 Release 事件:将只打包产物(不构建 Docker)"
|
||||
else
|
||||
# workflow_dispatch 事件:使用用户输入
|
||||
BUILD_TYPE="${{ github.event.inputs.build_type }}"
|
||||
echo "🔧 手动触发:构建类型 = ${BUILD_TYPE}"
|
||||
fi
|
||||
|
||||
echo "BUILD_TYPE=${BUILD_TYPE}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract version and check if pre-release
|
||||
id: extract_version
|
||||
run: |
|
||||
# 从 release tag 中提取版本号(例如 v1.0.0 -> 1.0.0)
|
||||
TAG_NAME="${{ github.event.release.tag_name }}"
|
||||
if [ -z "$TAG_NAME" ]; then
|
||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||
# 从不同事件源提取版本号
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
# Release 事件:从 release tag 中提取
|
||||
TAG_NAME="${{ github.event.release.tag_name }}"
|
||||
IS_PRERELEASE="${{ github.event.release.prerelease }}"
|
||||
else
|
||||
# workflow_dispatch 事件:从输入参数中提取
|
||||
TAG_NAME="${{ github.event.inputs.tag_name }}"
|
||||
if [ -z "$TAG_NAME" ]; then
|
||||
TAG_NAME="${{ github.event.inputs.version }}"
|
||||
fi
|
||||
# 如果仍然为空,尝试从 git ref 中提取
|
||||
if [ -z "$TAG_NAME" ]; then
|
||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
|
||||
# 不是 tag,尝试从分支名或 commit SHA 获取
|
||||
TAG_NAME=${GITHUB_REF#refs/heads/}
|
||||
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
|
||||
TAG_NAME="dev-$(date +%Y%m%d-%H%M%S)"
|
||||
echo "⚠️ 未指定版本号,使用临时版本: $TAG_NAME"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
IS_PRERELEASE="false"
|
||||
fi
|
||||
|
||||
# 验证版本号格式:v数字.数字.数字[-后缀](例如 v1.0.0, v2.10.102, v1.0.0-beta)
|
||||
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "错误: 版本号格式不正确,应为 v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
|
||||
exit 1
|
||||
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] && [[ ! "$TAG_NAME" =~ ^dev- ]]; then
|
||||
echo "⚠️ 警告: 版本号格式不符合标准,但仍将继续构建"
|
||||
echo " 当前版本号: $TAG_NAME"
|
||||
echo " 标准格式应为: v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
|
||||
fi
|
||||
|
||||
VERSION=${TAG_NAME#v} # 移除 v 前缀
|
||||
VERSION=${TAG_NAME#v} # 移除 v 前缀(如果存在)
|
||||
|
||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "TAG=$TAG_NAME" >> $GITHUB_OUTPUT
|
||||
echo "Extracted version: $VERSION"
|
||||
echo "Full tag: $TAG_NAME"
|
||||
echo "IS_PRERELEASE=$IS_PRERELEASE" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ "$IS_PRERELEASE" = "true" ]; then
|
||||
echo "📋 这是 Pre-release: $TAG_NAME"
|
||||
else
|
||||
echo "📦 这是正式版本: $TAG_NAME"
|
||||
fi
|
||||
|
||||
- name: Send Telegram notification (build started)
|
||||
if: steps.extract_version.outputs.IS_PRERELEASE == 'false' && steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
@@ -48,12 +113,15 @@ jobs:
|
||||
fi
|
||||
|
||||
# 获取构建信息
|
||||
VERSION="${{ steps.extract_version.outputs.VERSION }}"
|
||||
TAG="${{ steps.extract_version.outputs.TAG }}"
|
||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||
|
||||
# 构建消息内容(仅包含关键信息)
|
||||
MESSAGE="🔨 <b>Docker 镜像构建中</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||
MESSAGE="🔨 <b>Release 构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
|
||||
else
|
||||
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
MESSAGE="🔨 <b>构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"
|
||||
fi
|
||||
|
||||
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
@@ -79,19 +147,155 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ============ 编译前后端产物 ============
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Build Backend JAR
|
||||
run: |
|
||||
cd backend
|
||||
chmod +x gradlew
|
||||
./gradlew bootJar --no-daemon
|
||||
echo "✅ 后端构建完成"
|
||||
ls -lh build/libs/*.jar
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Build Frontend
|
||||
env:
|
||||
VERSION: ${{ steps.extract_version.outputs.VERSION }}
|
||||
GIT_TAG: ${{ steps.extract_version.outputs.TAG }}
|
||||
GITHUB_REPO_URL: https://github.com/WrBug/PolyHermes
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
echo "✅ 前端构建完成"
|
||||
echo "📦 版本信息: VERSION=${{ steps.extract_version.outputs.VERSION }}, GIT_TAG=${{ steps.extract_version.outputs.TAG }}"
|
||||
du -sh dist/
|
||||
|
||||
# ============ 打包更新包 ============
|
||||
- name: Create Update Package
|
||||
run: |
|
||||
echo "📦 开始打包更新包..."
|
||||
|
||||
# 创建目录结构
|
||||
mkdir -p update-package/backend
|
||||
mkdir -p update-package/frontend
|
||||
|
||||
# 复制后端 JAR
|
||||
cp backend/build/libs/*.jar update-package/backend/polyhermes.jar
|
||||
echo "✅ 后端 JAR 已复制"
|
||||
|
||||
# 复制前端产物
|
||||
cp -r frontend/dist/* update-package/frontend/
|
||||
echo "✅ 前端文件已复制"
|
||||
|
||||
# 创建版本信息文件
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
RELEASE_NOTES=$(echo '${{ github.event.release.body }}' | jq -Rs .)
|
||||
else
|
||||
RELEASE_NOTES="\"手动构建 - workflow_dispatch\""
|
||||
fi
|
||||
|
||||
cat > update-package/version.json <<EOF
|
||||
{
|
||||
"version": "${{ steps.extract_version.outputs.VERSION }}",
|
||||
"tag": "${{ steps.extract_version.outputs.TAG }}",
|
||||
"buildTime": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"releaseNotes": ${RELEASE_NOTES}
|
||||
}
|
||||
EOF
|
||||
echo "✅ 版本信息已创建"
|
||||
|
||||
# 打包成 tar.gz
|
||||
cd update-package
|
||||
tar -czf ../polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz .
|
||||
cd ..
|
||||
|
||||
echo "✅ 打包完成: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz"
|
||||
ls -lh polyhermes-*.tar.gz
|
||||
|
||||
- name: Calculate Checksum
|
||||
id: checksum
|
||||
run: |
|
||||
FILE="polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz"
|
||||
CHECKSUM=$(sha256sum "$FILE" | awk '{print $1}')
|
||||
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_OUTPUT
|
||||
echo "✅ SHA256: $CHECKSUM"
|
||||
echo "$CHECKSUM $FILE" > checksums.txt
|
||||
|
||||
- name: Upload Update Package to Release
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: ./polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
|
||||
asset_name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
|
||||
asset_content_type: application/gzip
|
||||
|
||||
- name: Upload Checksums to Release
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: ./checksums.txt
|
||||
asset_name: checksums.txt
|
||||
asset_content_type: text/plain
|
||||
|
||||
- name: Upload Update Package as Artifact
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update
|
||||
path: |
|
||||
polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
|
||||
checksums.txt
|
||||
retention-days: 30
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
# 启用多架构构建支持
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Prepare Docker build context
|
||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||
run: |
|
||||
echo "📦 准备 Docker 构建上下文..."
|
||||
# 确保构建产物存在且可访问
|
||||
if [ ! -d "frontend/dist" ]; then
|
||||
echo "❌ 错误:frontend/dist 不存在"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "backend/build/libs" ] || [ -z "$(ls -A backend/build/libs/*.jar 2>/dev/null)" ]; then
|
||||
echo "❌ 错误:backend/build/libs/*.jar 不存在"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ 构建产物已准备好"
|
||||
ls -lh frontend/dist/ | head -5
|
||||
ls -lh backend/build/libs/*.jar
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
@@ -101,15 +305,23 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
wrbug/polyhermes:${{ steps.extract_version.outputs.TAG }}
|
||||
wrbug/polyhermes:latest
|
||||
${{ steps.extract_version.outputs.IS_PRERELEASE == 'false' && 'wrbug/polyhermes:latest' || '' }}
|
||||
build-args: |
|
||||
BUILD_IN_DOCKER=false
|
||||
VERSION=${{ steps.extract_version.outputs.VERSION }}
|
||||
GIT_TAG=${{ steps.extract_version.outputs.TAG }}
|
||||
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
||||
cache-from: type=registry,ref=wrbug/polyhermes:latest
|
||||
cache-to: type=inline
|
||||
|
||||
- name: Skip Docker build notice
|
||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-only'
|
||||
run: |
|
||||
echo "⏭️ 跳过 Docker 镜像构建(构建类型:package-only)"
|
||||
echo "✅ 仅打包产物已完成"
|
||||
|
||||
- name: Send Telegram notification
|
||||
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
@@ -123,13 +335,26 @@ jobs:
|
||||
# 获取构建信息
|
||||
VERSION="${{ steps.extract_version.outputs.VERSION }}"
|
||||
TAG="${{ steps.extract_version.outputs.TAG }}"
|
||||
RELEASE_NAME="${{ github.event.release.name }}"
|
||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||
REPO_NAME="${{ github.repository }}"
|
||||
BUILD_TYPE="${{ steps.build_config.outputs.BUILD_TYPE }}"
|
||||
|
||||
# 构建消息内容(仅包含关键信息)
|
||||
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
|
||||
MESSAGE="✅ <b>Docker 镜像构建成功</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
|
||||
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
|
||||
MESSAGE="✅ <b>Release 构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
|
||||
else
|
||||
MESSAGE="✅ <b>Release 打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
|
||||
fi
|
||||
else
|
||||
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
|
||||
MESSAGE="✅ <b>构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
|
||||
else
|
||||
MESSAGE="✅ <b>打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
name: Telegram Notification on PR Merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed # 当 PR 被关闭(合并或关闭)时触发
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# 只在 PR 被合并到 main 分支时执行
|
||||
if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get PR details
|
||||
id: pr_details
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
REPO="${{ github.repository }}"
|
||||
|
||||
# 获取 PR 详细信息
|
||||
PR_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}")
|
||||
|
||||
# 获取 PR 变更的文件列表
|
||||
FILES_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}/files")
|
||||
|
||||
# 提取 PR 描述(body),保留换行,限制长度
|
||||
PR_BODY=$(echo "$PR_RESPONSE" | jq -r '.body // ""')
|
||||
if [ ${#PR_BODY} -gt 500 ]; then
|
||||
PR_BODY="${PR_BODY:0:500}..."
|
||||
fi
|
||||
|
||||
# 保存到输出变量(使用 base64 编码避免特殊字符问题)
|
||||
echo "pr_body<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$PR_BODY" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Send Telegram notification
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
run: |
|
||||
# 检查 PR 是否被合并(而不是仅关闭)
|
||||
PR_MERGED="${{ github.event.pull_request.merged }}"
|
||||
if [ "$PR_MERGED" != "true" ]; then
|
||||
echo "ℹ️ PR 仅关闭,未合并,跳过通知"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 检查必要的环境变量
|
||||
# 注意:TELEGRAM_CHAT_ID 可以是个人聊天 ID(正数)或群组 ID(负数,如 -1001234567890)
|
||||
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 获取 PR 基本信息
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_URL="${{ github.event.pull_request.html_url }}"
|
||||
PR_MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
|
||||
# 获取 PR 详细信息
|
||||
PR_BODY="${{ steps.pr_details.outputs.pr_body }}"
|
||||
|
||||
# 转义 PR 标题中的 HTML 特殊字符
|
||||
PR_TITLE_ESCAPED=$(echo "$PR_TITLE" | sed 's/&/\&/g' | sed 's/</\</g' | sed 's/>/\>/g')
|
||||
|
||||
# 构建消息内容(仅包含关键信息)
|
||||
MESSAGE="🚀 <b>main 分支代码更新</b>"$'\n'$'\n'"📝 <b>PR #${PR_NUMBER}:</b> ${PR_TITLE_ESCAPED}"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
|
||||
|
||||
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg chat_id "$TELEGRAM_CHAT_ID" \
|
||||
--arg text "$MESSAGE" \
|
||||
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
|
||||
|
||||
# 检查发送结果
|
||||
if [ $? -eq 0 ]; then
|
||||
RESPONSE=$(cat /tmp/telegram_response.json)
|
||||
if echo "$RESPONSE" | grep -q '"ok":true'; then
|
||||
echo "✅ Telegram 通知发送成功"
|
||||
else
|
||||
echo "❌ Telegram 通知发送失败: $RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ 发送 Telegram 消息时发生错误"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+4
-2
@@ -17,14 +17,16 @@ backend/out/
|
||||
backend/*.log
|
||||
backend/gradle-app.setting
|
||||
backend/.gradle
|
||||
backend/gradle-wrapper.jar
|
||||
|
||||
# 注意:gradle-wrapper.jar 应该被提交,不要忽略
|
||||
# backend/gradle/wrapper/gradle-wrapper.jar
|
||||
polyhub/
|
||||
# Kotlin
|
||||
*.kt.bak
|
||||
*.class
|
||||
|
||||
# Java
|
||||
*.jar
|
||||
!backend/gradle/wrapper/gradle-wrapper.jar # Gradle Wrapper JAR 应该被提交
|
||||
*.war
|
||||
*.ear
|
||||
*.class
|
||||
|
||||
+88
-31
@@ -1,6 +1,12 @@
|
||||
# 多阶段构建:前后端一体化部署
|
||||
# 阶段1:构建前端
|
||||
# 多阶段构建:前后端一体化部署(支持混合编译)
|
||||
# 构建参数:控制是否在 Docker 内编译
|
||||
# - BUILD_IN_DOCKER=true (默认): Docker 内部编译(本地开发)
|
||||
# - BUILD_IN_DOCKER=false: 使用外部产物(GitHub Actions)
|
||||
ARG BUILD_IN_DOCKER=true
|
||||
|
||||
# ==================== 阶段1:构建前端 ====================
|
||||
FROM node:18-alpine AS frontend-build
|
||||
ARG BUILD_IN_DOCKER
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
@@ -13,19 +19,37 @@ ARG GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
||||
ENV VERSION=${VERSION}
|
||||
ENV GIT_TAG=${GIT_TAG}
|
||||
ENV GITHUB_REPO_URL=${GITHUB_REPO_URL}
|
||||
|
||||
# 复制前端文件
|
||||
# 复制前端文件(先复制 package.json 以利用 Docker 缓存)
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# 条件:仅在 Docker 内部编译时安装依赖
|
||||
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||
npm ci; \
|
||||
fi
|
||||
|
||||
# 复制所有前端源文件
|
||||
COPY frontend/ ./
|
||||
|
||||
# 构建前端(使用相对路径,通过 Nginx 代理)
|
||||
# 版本号会通过环境变量注入到构建产物中
|
||||
RUN npm run build
|
||||
# 条件:仅在 Docker 内部编译时执行构建
|
||||
# 如果 BUILD_IN_DOCKER=false,需要确保构建上下文中存在 frontend/dist
|
||||
# 注意:COPY frontend/ ./ 已经复制了整个 frontend 目录(包括 dist,如果存在)
|
||||
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||
echo "🔨 Docker 内部编译前端..."; \
|
||||
npm run build; \
|
||||
else \
|
||||
echo "⏭️ 使用外部产物..."; \
|
||||
if [ ! -d "dist" ] || [ -z "$(ls -A dist 2>/dev/null)" ]; then \
|
||||
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 frontend/dist"; \
|
||||
echo " 请先执行: cd frontend && npm install && npm run build"; \
|
||||
exit 1; \
|
||||
else \
|
||||
echo "✅ 找到外部构建的前端产物"; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
# 阶段2:构建后端
|
||||
# ==================== 阶段2:构建后端 ====================
|
||||
FROM gradle:8.5-jdk17 AS backend-build
|
||||
ARG BUILD_IN_DOCKER
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
@@ -33,60 +57,93 @@ WORKDIR /app/backend
|
||||
COPY backend/build.gradle.kts backend/settings.gradle.kts ./
|
||||
COPY backend/gradle ./gradle
|
||||
|
||||
# 下载依赖(利用 Docker 缓存)
|
||||
RUN gradle dependencies --no-daemon || true
|
||||
# 条件:仅在 Docker 内部编译时下载依赖
|
||||
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||
gradle dependencies --no-daemon || true; \
|
||||
fi
|
||||
|
||||
# 复制源代码
|
||||
COPY backend/src ./src
|
||||
|
||||
# 构建应用
|
||||
RUN gradle bootJar --no-daemon
|
||||
# 尝试复制外部构建的 JAR(如果存在)
|
||||
# 注意:COPY 指令如果源不存在会失败
|
||||
# GitHub Actions 使用 BUILD_IN_DOCKER=false,会先构建产物,所以 backend/build 应该存在
|
||||
# 本地开发使用 BUILD_IN_DOCKER=true,会在 Docker 内编译,所以 backend/build 可能不存在
|
||||
# 解决方案:先复制整个 backend 目录(包括 build,如果存在),然后只使用需要的部分
|
||||
# 使用 .dockerignore 确保不会复制不需要的文件(如 .gradle、out、bin 等)
|
||||
COPY backend/build ./build-external
|
||||
|
||||
# 阶段3:运行环境
|
||||
# 处理外部构建的 JAR(如果存在)
|
||||
RUN if [ -d "build-external/libs" ] && [ -n "$(ls -A build-external/libs/*.jar 2>/dev/null)" ]; then \
|
||||
echo "📦 找到外部构建的后端产物,复制到 build/libs..."; \
|
||||
mkdir -p build/libs; \
|
||||
cp build-external/libs/*.jar build/libs/; \
|
||||
rm -rf build-external; \
|
||||
else \
|
||||
echo "⏭️ 未找到外部构建的 JAR,将在 Docker 内编译"; \
|
||||
rm -rf build-external; \
|
||||
mkdir -p build/libs; \
|
||||
fi
|
||||
|
||||
# 条件:仅在 Docker 内部编译时执行构建(会覆盖外部产物)
|
||||
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||
echo "🔨 Docker 内部编译后端..."; \
|
||||
gradle bootJar --no-daemon; \
|
||||
else \
|
||||
echo "⏭️ 使用外部产物"; \
|
||||
if [ -z "$(ls -A build/libs/*.jar 2>/dev/null)" ]; then \
|
||||
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 backend/build/libs/*.jar"; \
|
||||
echo " 请先执行: cd backend && ./gradlew bootJar"; \
|
||||
exit 1; \
|
||||
else \
|
||||
echo "✅ 使用外部构建的后端产物"; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
# ==================== 阶段3:运行环境 ====================
|
||||
FROM eclipse-temurin:17-jre-jammy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装 Nginx 和必要的工具(包含时区数据)
|
||||
# 安装 Nginx、Python 和必要的工具
|
||||
RUN apt-get update && \
|
||||
apt-get install -y nginx curl tzdata && \
|
||||
apt-get install -y nginx curl tzdata jq python3 python3-flask python3-requests && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
rm -rf /etc/nginx/sites-enabled/default
|
||||
|
||||
# 从构建阶段复制文件
|
||||
# 当 BUILD_IN_DOCKER=false 时,构建阶段已经复制了外部产物
|
||||
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html
|
||||
COPY --from=backend-build /app/backend/build/libs/*.jar app.jar
|
||||
|
||||
# 复制 Nginx 配置
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# 创建启动脚本
|
||||
# 创建更新服务相关目录和脚本
|
||||
RUN mkdir -p /app/updates /app/backups /var/log/polyhermes
|
||||
COPY docker/update-service.py /app/update-service.py
|
||||
COPY docker/start.sh /app/start.sh
|
||||
RUN chmod +x /app/start.sh
|
||||
|
||||
# 创建非 root 用户(用于运行后端应用)
|
||||
# 记录初始版本(从构建参数)
|
||||
ARG VERSION=dev
|
||||
ARG GIT_TAG=dev
|
||||
RUN echo "{\"version\":\"${VERSION}\",\"tag\":\"${GIT_TAG}\",\"buildTime\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > /app/version.json
|
||||
|
||||
# 创建非 root 用户
|
||||
RUN useradd -m -u 1000 appuser
|
||||
|
||||
# 设置目录权限(Nginx 以 root 运行,后端应用以 appuser 运行)
|
||||
# 设置目录权限
|
||||
RUN mkdir -p /var/log/nginx /var/lib/nginx /var/cache/nginx /var/run && \
|
||||
chown -R appuser:appuser /app && \
|
||||
chown -R root:root /usr/share/nginx/html && \
|
||||
chown -R root:root /var/log/nginx && \
|
||||
chown -R root:root /var/lib/nginx && \
|
||||
chown -R root:root /var/cache/nginx && \
|
||||
chown -R root:root /etc/nginx && \
|
||||
chown -R root:root /var/run
|
||||
|
||||
# 保持 root 用户(Nginx 需要 root 权限绑定 80 端口)
|
||||
# USER appuser
|
||||
chown -R root:root /usr/share/nginx/html /var/log/nginx /var/lib/nginx /var/cache/nginx /etc/nginx /var/run
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 80
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||
CMD curl -f http://localhost/api/health || exit 1
|
||||
CMD curl -f http://localhost/api/system/health || exit 1
|
||||
|
||||
# 启动服务(同时启动 Nginx 和后端)
|
||||
# 启动服务
|
||||
ENTRYPOINT ["/app/start.sh"]
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
- **API 健康检查**:实时监控 Polymarket API 的健康状态
|
||||
- **用户管理**:管理系统用户,支持添加、编辑、删除用户
|
||||
- **公告管理**:查看系统公告和更新信息
|
||||
- **动态更新**:支持在线更新系统版本,无需重新部署容器
|
||||
|
||||
### 🚀 技术特性
|
||||
|
||||
@@ -164,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 代码,推荐)**
|
||||
@@ -397,6 +421,7 @@ cd frontend
|
||||
- [开发文档](docs/zh/DEVELOPMENT.md) - 开发指南
|
||||
- [跟单系统需求文档](docs/zh/copy-trading-requirements.md) - 后端 API 接口文档
|
||||
- [前端需求文档](docs/zh/copy-trading-frontend-requirements.md) - 前端功能文档
|
||||
- [动态更新文档](docs/zh/DYNAMIC_UPDATE.md) - 动态更新功能说明
|
||||
|
||||
### 🤝 贡献指南
|
||||
|
||||
|
||||
@@ -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 支持一下!**
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
## 版本信息
|
||||
- **版本号**: v1.1.15
|
||||
- **发布日期**: 2026-01-19
|
||||
- **基础版本**: v1.1.14
|
||||
|
||||
## 改动摘要
|
||||
本次版本优化了订单详情处理逻辑,提升了系统稳定性和开发体验。
|
||||
|
||||
## 主要改动
|
||||
|
||||
### 🐛 Bug 修复
|
||||
|
||||
#### 1. 优化订单详情为 null 时的处理逻辑
|
||||
- **提交**: 7385eff
|
||||
- **文件**: OrderStatusUpdateService.kt
|
||||
- **问题**:
|
||||
- 订单详情为 null 且已发送通知超过 60 秒时,订单被直接删除
|
||||
- 导致已经正确处理并发送 TG 通知的订单被意外删除
|
||||
- **修复**:
|
||||
- 当订单详情为 null 且 notificationSent = true 超过 60 秒时,将订单状态改为 fully_matched
|
||||
- fully_matched 状态的订单会被自动过滤,不再查询详情
|
||||
- 避免已处理的订单被误删除
|
||||
|
||||
### 🧹 日志清理
|
||||
|
||||
#### 2. 清理 MarketPollingService 中多余的 debug 日志
|
||||
- **提交**: d768da7, 07b4d65
|
||||
- **文件**: MarketPollingService.kt
|
||||
- **改进**:
|
||||
- 删除多余的 debug 日志输出
|
||||
- 减少冗余日志,提升日志可读性
|
||||
- 优化性能(减少日志 I/O)
|
||||
|
||||
### ✨ 新增功能
|
||||
|
||||
#### 3. 添加订单详情查询工具脚本
|
||||
- **提交**: b658270
|
||||
- **新增文件**:
|
||||
- scripts/get-order-detail.js - 订单详情查询脚本
|
||||
- scripts/package.json - 依赖配置文件
|
||||
- **功能**:
|
||||
- 快速查询 Polymarket 订单详情
|
||||
- 支持自动创建 API Key
|
||||
- 完善的错误处理和参数验证
|
||||
- 详细的订单信息输出
|
||||
|
||||
## 文件变更统计
|
||||
- **修改文件数**: 3
|
||||
- **新增文件数**: 2
|
||||
- **新增行数**: 192
|
||||
- **删除行数**: 6
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 订单状态管理优化
|
||||
- 使用 fully_matched 状态标记已处理订单
|
||||
- 通过数据库查询条件自动过滤,无需额外缓存
|
||||
- 保持数据一致性和可追溯性
|
||||
|
||||
### 工具脚本特性
|
||||
- 基于 Polymarket CLOB Client v5.2.1
|
||||
- 支持 derive/create API Key 自动处理
|
||||
- 完整的参数验证和错误提示
|
||||
- 友好的命令行交互体验
|
||||
|
||||
## 升级建议
|
||||
- 无需特殊操作,直接部署即可
|
||||
- 建议验证订单处理逻辑是否正常工作
|
||||
- 可以使用新增的工具脚本进行调试
|
||||
|
||||
## 完整提交列表
|
||||
- 7385eff - 优化订单详情为null时的处理逻辑
|
||||
- d768da7 - 清理 MarketPollingService 中多余的 debug 日志
|
||||
- b658270 - 添加订单详情查询脚本
|
||||
- 07b4d65 - 清理 MarketPollingService 调试日志
|
||||
|
||||
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# 清理 Flyway V29 失败记录的脚本
|
||||
|
||||
echo "=== 清理 Flyway V29 失败记录 ==="
|
||||
echo ""
|
||||
echo "请确保 MySQL 正在运行,然后输入数据库密码"
|
||||
echo ""
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST="localhost"
|
||||
DB_PORT="3306"
|
||||
DB_NAME="polymarket_bot"
|
||||
DB_USER="root"
|
||||
|
||||
# 检查 MySQL 命令是否可用
|
||||
if ! command -v mysql &> /dev/null; then
|
||||
echo "❌ 错误: 未找到 mysql 命令"
|
||||
echo ""
|
||||
echo "请使用数据库客户端(如 Navicat、DataGrip 等)执行以下 SQL:"
|
||||
echo ""
|
||||
echo "-- 1. 查看 Flyway 历史记录"
|
||||
echo "SELECT version, description, installed_on, success "
|
||||
echo "FROM flyway_schema_history "
|
||||
echo "WHERE version >= 28"
|
||||
echo "ORDER BY installed_rank;"
|
||||
echo ""
|
||||
echo "-- 2. 删除 V29 的失败记录"
|
||||
echo "DELETE FROM flyway_schema_history WHERE version = '29';"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 执行清理
|
||||
echo "正在连接数据库..."
|
||||
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p "$DB_NAME" << 'EOF'
|
||||
-- 查看当前状态
|
||||
SELECT '=== 当前 Flyway 历史记录 ===' as '';
|
||||
SELECT version, description, installed_on, success
|
||||
FROM flyway_schema_history
|
||||
WHERE version >= 28
|
||||
ORDER BY installed_rank;
|
||||
|
||||
-- 删除 V29 失败记录
|
||||
SELECT '=== 删除 V29 记录 ===' as '';
|
||||
DELETE FROM flyway_schema_history WHERE version = '29';
|
||||
|
||||
-- 确认删除结果
|
||||
SELECT CONCAT('已删除 ', ROW_COUNT(), ' 条记录') as result;
|
||||
|
||||
-- 再次查看状态
|
||||
SELECT '=== 清理后的 Flyway 历史记录 ===' as '';
|
||||
SELECT version, description, installed_on, success
|
||||
FROM flyway_schema_history
|
||||
WHERE version >= 28
|
||||
ORDER BY installed_rank;
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "✅ 清理完成!现在可以重启应用了"
|
||||
BIN
Binary file not shown.
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.controller.auth
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.repository.UserRepository
|
||||
import com.wrbug.polymarketbot.service.auth.AuthService
|
||||
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
@@ -18,7 +19,8 @@ import org.springframework.web.bind.annotation.*
|
||||
class AuthController(
|
||||
private val authService: AuthService,
|
||||
private val messageSource: MessageSource,
|
||||
private val webSocketTicketService: WebSocketTicketService
|
||||
private val webSocketTicketService: WebSocketTicketService,
|
||||
private val userRepository: UserRepository
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AuthController::class.java)
|
||||
@@ -184,5 +186,32 @@ class AuthController(
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取票据失败", messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证当前用户权限
|
||||
* 用于动态更新服务验证管理员权限
|
||||
* 管理员权限判断:是否为默认账户(isDefault == true)
|
||||
*/
|
||||
@GetMapping("/verify")
|
||||
fun verify(httpRequest: HttpServletRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
// 从请求属性中获取用户名(由 JWT 拦截器设置)
|
||||
val username = httpRequest.getAttribute("username") as? String
|
||||
if (username == null) {
|
||||
return ResponseEntity.status(401).body(ApiResponse.error(ErrorCode.AUTH_ERROR, "未认证", messageSource))
|
||||
}
|
||||
|
||||
// 检查是否为默认账户(管理员)
|
||||
val user = userRepository.findByUsername(username)
|
||||
if (user == null || !user.isDefault) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error(ErrorCode.AUTH_ERROR, "需要管理员权限", messageSource))
|
||||
}
|
||||
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
} catch (e: Exception) {
|
||||
logger.error("验证权限异常: ${e.message}", e)
|
||||
ResponseEntity.status(500).body(ApiResponse.error(ErrorCode.SERVER_ERROR, "验证失败", messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
package com.wrbug.polymarketbot.controller.backtest
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.backtest.BacktestService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* 回测管理控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/backtest")
|
||||
class BacktestController(
|
||||
private val backtestService: BacktestService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(BacktestController::class.java)
|
||||
|
||||
/**
|
||||
* 创建回测任务
|
||||
*/
|
||||
@PostMapping("/tasks")
|
||||
fun createBacktestTask(@RequestBody request: BacktestCreateRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
|
||||
return try {
|
||||
logger.info("创建回测任务: taskName=${request.taskName}, leaderId=${request.leaderId}")
|
||||
|
||||
val result = runBlocking {
|
||||
backtestService.createBacktestTask(request)
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { dto ->
|
||||
logger.info("回测任务创建成功: taskId=${dto.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(dto))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("创建回测任务失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.PARAM_ERROR
|
||||
else -> ErrorCode.SERVER_BACKTEST_CREATE_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建回测任务异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_CREATE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测任务列表
|
||||
*/
|
||||
@PostMapping("/tasks/list")
|
||||
fun getBacktestTaskList(@RequestBody request: BacktestListRequest): ResponseEntity<ApiResponse<BacktestListResponse>> {
|
||||
return try {
|
||||
val result = backtestService.getBacktestTaskList(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("查询回测任务列表成功: total=${response.total}")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询回测任务列表失败", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测任务列表异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测任务详情
|
||||
*/
|
||||
@PostMapping("/tasks/detail")
|
||||
fun getBacktestTaskDetail(@RequestBody request: BacktestDetailRequest): ResponseEntity<ApiResponse<BacktestDetailResponse>> {
|
||||
return try {
|
||||
val result = backtestService.getBacktestTaskDetail(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("查询回测任务详情成功: taskId=${request.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询回测任务详情失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||
else -> ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测任务详情异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测交易记录
|
||||
*/
|
||||
@PostMapping("/tasks/trades")
|
||||
fun getBacktestTrades(@RequestBody request: BacktestTradeListRequest): ResponseEntity<ApiResponse<BacktestTradeListResponse>> {
|
||||
return try {
|
||||
val result = backtestService.getBacktestTrades(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("查询回测交易记录成功: taskId=${request.taskId}")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询回测交易记录失败", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测交易记录异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除回测任务
|
||||
*/
|
||||
@PostMapping("/tasks/delete")
|
||||
fun deleteBacktestTask(@RequestBody request: BacktestDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
logger.info("删除回测任务: taskId=${request.id}")
|
||||
|
||||
val result = backtestService.deleteBacktestTask(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("回测任务删除成功: taskId=${request.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("删除回测任务失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
|
||||
else -> ErrorCode.SERVER_BACKTEST_DELETE_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除回测任务异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DELETE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止回测任务
|
||||
*/
|
||||
@PostMapping("/tasks/stop")
|
||||
fun stopBacktestTask(@RequestBody request: BacktestStopRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
logger.info("停止回测任务: taskId=${request.id}")
|
||||
|
||||
val result = backtestService.stopBacktestTask(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("回测任务停止成功: taskId=${request.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("停止回测任务失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
|
||||
else -> ErrorCode.SERVER_BACKTEST_STOP_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("停止回测任务异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_STOP_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试回测任务
|
||||
*/
|
||||
@PostMapping("/tasks/retry")
|
||||
fun retryBacktestTask(@RequestBody request: BacktestRetryRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
logger.info("重试回测任务: taskId=${request.id}")
|
||||
|
||||
val result = backtestService.retryBacktestTask(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("回测任务重试成功: taskId=${request.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("重试回测任务失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
|
||||
else -> ErrorCode.SERVER_BACKTEST_RETRY_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("重试回测任务异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RETRY_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前配置重新测试:基于已完成的回测任务创建相同配置的新任务(仅支持已完成任务)
|
||||
*/
|
||||
@PostMapping("/tasks/rerun")
|
||||
fun rerunBacktestTask(@RequestBody request: BacktestRerunRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
|
||||
return try {
|
||||
logger.info("按配置重新测试: sourceTaskId=${request.id}, newTaskName=${request.taskName}")
|
||||
|
||||
val result = backtestService.rerunBacktestTask(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { dto ->
|
||||
logger.info("重新测试任务创建成功: newTaskId=${dto.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(dto))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("按配置重新测试失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||
is IllegalStateException -> ErrorCode.BACKTEST_TASK_NOT_COMPLETED
|
||||
else -> ErrorCode.SERVER_BACKTEST_RERUN_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("按配置重新测试异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RERUN_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-1
@@ -140,7 +140,7 @@ class LeaderController(
|
||||
if (request.leaderId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
|
||||
|
||||
val result = leaderService.getLeaderDetail(request.leaderId)
|
||||
result.fold(
|
||||
onSuccess = { leader ->
|
||||
@@ -159,6 +159,36 @@ class LeaderController(
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_DETAIL_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询被跟单者余额
|
||||
*/
|
||||
@PostMapping("/balance")
|
||||
fun getLeaderBalance(@RequestBody request: LeaderBalanceRequest): ResponseEntity<ApiResponse<LeaderBalanceResponse>> {
|
||||
return try {
|
||||
if (request.leaderId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
|
||||
val result = leaderService.getLeaderBalance(request.leaderId)
|
||||
result.fold(
|
||||
onSuccess = { balance ->
|
||||
ResponseEntity.ok(ApiResponse.success(balance))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询 Leader 余额失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
|
||||
is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.BUSINESS_ERROR, e.message, messageSource))
|
||||
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询 Leader 余额异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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个)
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户更新请求
|
||||
*/
|
||||
@@ -93,6 +124,16 @@ data class AccountListResponse(
|
||||
val total: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 钱包余额响应(通用类,用于 Account 和 Leader)
|
||||
*/
|
||||
data class WalletBalanceResponse(
|
||||
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
|
||||
val positionBalance: String, // 仓位余额(持仓总价值)
|
||||
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
|
||||
val positions: List<PositionDto> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户余额响应
|
||||
*/
|
||||
@@ -108,6 +149,7 @@ data class AccountBalanceResponse(
|
||||
*/
|
||||
data class PositionDto(
|
||||
val marketId: String,
|
||||
val title: String?, // 市场名称
|
||||
val side: String, // YES 或 NO
|
||||
val quantity: String,
|
||||
val avgPrice: String,
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 回测任务创建请求
|
||||
*/
|
||||
data class BacktestCreateRequest(
|
||||
val taskName: String, // 回测任务名称
|
||||
val leaderId: Long, // Leader ID
|
||||
val initialBalance: String, // 初始资金
|
||||
val backtestDays: Int, // 回测天数 (1-30)
|
||||
// 跟单配置(与 CopyTrading 一致,但不包含 max_position_count)
|
||||
val copyMode: String? = null, // "RATIO" 或 "FIXED"
|
||||
val copyRatio: String? = null, // 仅在 copyMode="RATIO" 时生效
|
||||
val fixedAmount: String? = null, // 仅在 copyMode="FIXED" 时生效
|
||||
val maxOrderSize: String? = null,
|
||||
val minOrderSize: String? = null,
|
||||
val maxDailyLoss: String? = null,
|
||||
val maxDailyOrders: Int? = null,
|
||||
val supportSell: Boolean? = null,
|
||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
val keywords: List<String>? = null, // 关键字列表
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
val pageForResume: Int? = null // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务列表请求
|
||||
*/
|
||||
data class BacktestListRequest(
|
||||
val leaderId: Long? = null, // Leader ID(可选)
|
||||
val status: String? = null, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
|
||||
val sortBy: String? = null, // profitAmount / profitRate / createdAt
|
||||
val sortOrder: String? = null, // asc / desc
|
||||
val page: Int = 1, // 页码,从1开始
|
||||
val size: Int = 20 // 每页数量
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务详情请求
|
||||
*/
|
||||
data class BacktestDetailRequest(
|
||||
val id: Long // 回测任务ID
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测交易记录请求
|
||||
*/
|
||||
data class BacktestTradeListRequest(
|
||||
val taskId: Long, // 回测任务ID
|
||||
val page: Int = 1, // 页码,从1开始
|
||||
val size: Int = 20 // 每页数量
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测进度查询请求
|
||||
*/
|
||||
data class BacktestProgressRequest(
|
||||
val id: Long // 回测任务ID
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务停止请求
|
||||
*/
|
||||
data class BacktestStopRequest(
|
||||
val id: Long // 回测任务ID
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务删除请求
|
||||
*/
|
||||
data class BacktestDeleteRequest(
|
||||
val id: Long // 回测任务ID
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务重试请求
|
||||
*/
|
||||
data class BacktestRetryRequest(
|
||||
val id: Long // 回测任务ID
|
||||
)
|
||||
|
||||
/**
|
||||
* 按当前配置重新测试请求(仅支持已完成任务)
|
||||
*/
|
||||
data class BacktestRerunRequest(
|
||||
val id: Long, // 源回测任务ID
|
||||
val taskName: String? = null // 新任务名称,为空时使用「原名称 (副本)」
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务列表响应
|
||||
*/
|
||||
data class BacktestListResponse(
|
||||
val list: List<BacktestTaskDto>,
|
||||
val total: Long,
|
||||
val page: Int,
|
||||
val size: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务详情响应
|
||||
*/
|
||||
data class BacktestDetailResponse(
|
||||
val task: BacktestTaskDto,
|
||||
val config: BacktestConfigDto,
|
||||
val statistics: BacktestStatisticsDto
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测交易记录列表响应
|
||||
*/
|
||||
data class BacktestTradeListResponse(
|
||||
val list: List<BacktestTradeDto>,
|
||||
val total: Long,
|
||||
val page: Int,
|
||||
val size: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测进度响应
|
||||
*/
|
||||
data class BacktestProgressResponse(
|
||||
val progress: Int, // 执行进度 (0-100)
|
||||
val currentBalance: String, // 当前余额
|
||||
val totalTrades: Int, // 总交易笔数
|
||||
val status: String // 任务状态
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务 DTO
|
||||
*/
|
||||
data class BacktestTaskDto(
|
||||
val id: Long,
|
||||
val taskName: String,
|
||||
val leaderId: Long,
|
||||
val leaderName: String?,
|
||||
val leaderAddress: String?,
|
||||
val initialBalance: String,
|
||||
val finalBalance: String?,
|
||||
val profitAmount: String?,
|
||||
val profitRate: String?,
|
||||
val backtestDays: Int,
|
||||
val startTime: Long,
|
||||
val endTime: Long?,
|
||||
val status: String, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
|
||||
val progress: Int,
|
||||
val totalTrades: Int,
|
||||
val createdAt: Long,
|
||||
val executionStartedAt: Long?,
|
||||
val executionFinishedAt: Long?
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测配置 DTO
|
||||
*/
|
||||
data class BacktestConfigDto(
|
||||
val copyMode: String,
|
||||
val copyRatio: String,
|
||||
val fixedAmount: String?,
|
||||
val maxOrderSize: String,
|
||||
val minOrderSize: String,
|
||||
val maxDailyLoss: String,
|
||||
val maxDailyOrders: Int,
|
||||
val supportSell: Boolean,
|
||||
val keywordFilterMode: String?,
|
||||
val keywords: List<String>?,
|
||||
val maxPositionValue: String?,
|
||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? // 最高价格(可选),NULL表示不限制最高价
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测统计信息 DTO
|
||||
*/
|
||||
data class BacktestStatisticsDto(
|
||||
val totalTrades: Int, // 总交易笔数
|
||||
val buyTrades: Int, // 买入笔数
|
||||
val sellTrades: Int, // 卖出笔数
|
||||
val winTrades: Int, // 盈利交易笔数
|
||||
val lossTrades: Int, // 亏损交易笔数
|
||||
val winRate: String, // 胜率(%)
|
||||
val maxProfit: String, // 最大单笔盈利
|
||||
val maxLoss: String, // 最大单笔亏损
|
||||
val maxDrawdown: String, // 最大回撤
|
||||
val avgHoldingTime: Long? // 平均持仓时间(毫秒)
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测交易记录 DTO
|
||||
*/
|
||||
data class BacktestTradeDto(
|
||||
val id: Long,
|
||||
val tradeTime: Long,
|
||||
val marketId: String,
|
||||
val marketTitle: String?,
|
||||
val side: String, // BUY/SELL/SETTLEMENT
|
||||
val outcome: String,
|
||||
val outcomeIndex: Int?,
|
||||
val quantity: String,
|
||||
val price: String,
|
||||
val amount: String,
|
||||
val fee: String,
|
||||
val profitLoss: String?,
|
||||
val balanceAfter: String,
|
||||
val leaderTradeId: String?
|
||||
)
|
||||
|
||||
@@ -36,7 +36,6 @@ data class CopyTradingCreateRequest(
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 关键字过滤配置
|
||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||
@@ -75,7 +74,6 @@ data class CopyTradingUpdateRequest(
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 关键字过滤配置
|
||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||
@@ -151,7 +149,6 @@ data class CopyTradingDto(
|
||||
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 关键字过滤配置
|
||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||
|
||||
@@ -36,6 +36,13 @@ data class LeaderListRequest(
|
||||
val category: String? = null // sports 或 crypto
|
||||
)
|
||||
|
||||
/**
|
||||
* Leader 余额请求
|
||||
*/
|
||||
data class LeaderBalanceRequest(
|
||||
val leaderId: Long // LeaderID(必需)
|
||||
)
|
||||
|
||||
/**
|
||||
* Leader 信息响应
|
||||
*/
|
||||
@@ -47,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,
|
||||
@@ -61,3 +69,16 @@ data class LeaderListResponse(
|
||||
val total: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* Leader 余额响应
|
||||
*/
|
||||
data class LeaderBalanceResponse(
|
||||
val leaderId: Long,
|
||||
val leaderAddress: String,
|
||||
val leaderName: String?,
|
||||
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
|
||||
val positionBalance: String, // 仓位余额(持仓总价值)
|
||||
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
|
||||
val positions: List<PositionDto> = emptyList()
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 用户交易数据
|
||||
* 用于回测功能,从 Polymarket API 获取的用户交易历史
|
||||
*/
|
||||
data class TradeData(
|
||||
val tradeId: String, // 交易 ID
|
||||
val marketId: String, // 市场 ID
|
||||
val marketTitle: String?, // 市场标题
|
||||
val marketSlug: String?, // 市场 Slug
|
||||
val side: String, // 交易方向: BUY/SELL
|
||||
val outcome: String, // 结果: YES/NO 或 outcomeIndex
|
||||
val outcomeIndex: Int?, // 结果索引
|
||||
val price: BigDecimal, // 成交价格
|
||||
val size: BigDecimal, // 成交数量
|
||||
val amount: BigDecimal, // 成交金额
|
||||
val timestamp: Long // 交易时间戳
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is TradeData) return false
|
||||
return tradeId == other.tradeId
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return tradeId.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,11 @@ data class Account(
|
||||
@Column(name = "private_key", nullable = false, length = 500)
|
||||
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(可选,明文存储)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
|
||||
/**
|
||||
* 回测任务实体
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "backtest_task")
|
||||
data class BacktestTask(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "task_name", nullable = false, length = 100)
|
||||
val taskName: String,
|
||||
|
||||
@Column(name = "leader_id", nullable = false)
|
||||
val leaderId: Long,
|
||||
|
||||
// 回测参数
|
||||
@Column(name = "initial_balance", nullable = false, precision = 20, scale = 8)
|
||||
val initialBalance: BigDecimal,
|
||||
|
||||
@Column(name = "final_balance", precision = 20, scale = 8)
|
||||
var finalBalance: BigDecimal? = null,
|
||||
|
||||
@Column(name = "profit_amount", precision = 20, scale = 8)
|
||||
var profitAmount: BigDecimal? = null,
|
||||
|
||||
@Column(name = "profit_rate", precision = 10, scale = 4)
|
||||
var profitRate: BigDecimal? = null, // 收益率(%)
|
||||
|
||||
@Column(name = "backtest_days", nullable = false)
|
||||
val backtestDays: Int,
|
||||
|
||||
@Column(name = "start_time", nullable = false)
|
||||
val startTime: Long, // 回测开始时间(历史时间),创建时计算;执行时以当前时间为基准用局部变量重算窗口
|
||||
|
||||
@Column(name = "end_time")
|
||||
var endTime: Long? = null, // 回测结束时间(历史时间)
|
||||
|
||||
// 跟单配置 (复制CopyTrading表结构,但不包含 max_position_count)
|
||||
@Column(name = "copy_mode", nullable = false, length = 10)
|
||||
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
|
||||
|
||||
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
|
||||
val copyRatio: BigDecimal = BigDecimal.ONE,
|
||||
|
||||
@Column(name = "fixed_amount", precision = 20, scale = 8)
|
||||
val fixedAmount: BigDecimal? = null,
|
||||
|
||||
@Column(name = "max_order_size", nullable = false, precision = 20, scale = 8)
|
||||
val maxOrderSize: BigDecimal = "1000".toSafeBigDecimal(),
|
||||
|
||||
@Column(name = "min_order_size", nullable = false, precision = 20, scale = 8)
|
||||
val minOrderSize: BigDecimal = "1".toSafeBigDecimal(),
|
||||
|
||||
@Column(name = "max_daily_loss", nullable = false, precision = 20, scale = 8)
|
||||
val maxDailyLoss: BigDecimal = "10000".toSafeBigDecimal(),
|
||||
|
||||
@Column(name = "max_daily_orders", nullable = false)
|
||||
val maxDailyOrders: Int = 100,
|
||||
|
||||
@Column(name = "support_sell", nullable = false)
|
||||
val supportSell: Boolean = true,
|
||||
|
||||
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
|
||||
val keywordFilterMode: String = "DISABLED", // DISABLED/WHITELIST/BLACKLIST
|
||||
|
||||
@Column(name = "keywords", columnDefinition = "JSON")
|
||||
val keywords: String? = null,
|
||||
|
||||
@Column(name = "max_position_value", precision = 20, scale = 8)
|
||||
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
|
||||
@Column(name = "min_price", precision = 20, scale = 8)
|
||||
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
|
||||
@Column(name = "max_price", precision = 20, scale = 8)
|
||||
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
|
||||
// 统计字段
|
||||
@Column(name = "avg_holding_time")
|
||||
var avgHoldingTime: Long? = null, // 平均持仓时间(毫秒)
|
||||
|
||||
@Column(name = "data_source", length = 50)
|
||||
var dataSource: String = "MIXED", // INTERNAL/API/MIXED
|
||||
|
||||
// 执行状态
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
var status: String = "PENDING", // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
|
||||
|
||||
@Column(name = "progress", nullable = false)
|
||||
var progress: Int = 0, // 执行进度(0-100)
|
||||
|
||||
@Column(name = "total_trades", nullable = false)
|
||||
var totalTrades: Int = 0,
|
||||
|
||||
@Column(name = "buy_trades", nullable = false)
|
||||
var buyTrades: Int = 0,
|
||||
|
||||
@Column(name = "sell_trades", nullable = false)
|
||||
var sellTrades: Int = 0,
|
||||
|
||||
@Column(name = "win_trades", nullable = false)
|
||||
var winTrades: Int = 0,
|
||||
|
||||
@Column(name = "loss_trades", nullable = false)
|
||||
var lossTrades: Int = 0,
|
||||
|
||||
@Column(name = "win_rate", precision = 5, scale = 2)
|
||||
var winRate: BigDecimal? = null, // 胜率(%)
|
||||
|
||||
@Column(name = "max_profit", precision = 20, scale = 8)
|
||||
var maxProfit: BigDecimal? = null, // 最大单笔盈利
|
||||
|
||||
@Column(name = "max_loss", precision = 20, scale = 8)
|
||||
var maxLoss: BigDecimal? = null, // 最大单笔亏损
|
||||
|
||||
@Column(name = "max_drawdown", precision = 20, scale = 8)
|
||||
var maxDrawdown: BigDecimal? = null, // 最大回撤
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
var errorMessage: String? = null,
|
||||
|
||||
// 时间字段
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "execution_started_at")
|
||||
var executionStartedAt: Long? = null,
|
||||
|
||||
@Column(name = "execution_finished_at")
|
||||
var executionFinishedAt: Long? = null,
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "last_processed_trade_time")
|
||||
var lastProcessedTradeTime: Long? = null,
|
||||
|
||||
@Column(name = "last_processed_trade_index")
|
||||
var lastProcessedTradeIndex: Int? = null,
|
||||
|
||||
@Column(name = "processed_trade_count")
|
||||
var processedTradeCount: Int = 0
|
||||
)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 回测交易记录实体
|
||||
* 用于记录回测过程中的每笔模拟交易
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "backtest_trade")
|
||||
data class BacktestTrade(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "backtest_task_id", nullable = false)
|
||||
val backtestTaskId: Long,
|
||||
|
||||
@Column(name = "trade_time", nullable = false)
|
||||
val tradeTime: Long,
|
||||
|
||||
@Column(name = "market_id", nullable = false, length = 100)
|
||||
val marketId: String,
|
||||
|
||||
@Column(name = "market_title", length = 500)
|
||||
val marketTitle: String? = null,
|
||||
|
||||
@Column(name = "side", nullable = false, length = 20)
|
||||
val side: String, // BUY/SELL/SETTLEMENT
|
||||
|
||||
@Column(name = "outcome", nullable = false, length = 50)
|
||||
val outcome: String, // YES/NO 或 outcomeIndex
|
||||
|
||||
@Column(name = "outcome_index")
|
||||
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2, ...),支持多元市场
|
||||
|
||||
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
|
||||
val quantity: BigDecimal,
|
||||
|
||||
@Column(name = "price", nullable = false, precision = 20, scale = 8)
|
||||
val price: BigDecimal,
|
||||
|
||||
@Column(name = "amount", nullable = false, precision = 20, scale = 8)
|
||||
val amount: BigDecimal,
|
||||
|
||||
@Column(name = "fee", nullable = false, precision = 20, scale = 8)
|
||||
val fee: BigDecimal = BigDecimal.ZERO, // 手续费(回测不计算,默认为0)
|
||||
|
||||
@Column(name = "profit_loss", precision = 20, scale = 8)
|
||||
val profitLoss: BigDecimal? = null, // 盈亏(仅卖出时)
|
||||
|
||||
@Column(name = "balance_after", nullable = false, precision = 20, scale = 8)
|
||||
val balanceAfter: BigDecimal, // 交易后余额
|
||||
|
||||
@Column(name = "leader_trade_id", length = 100)
|
||||
val leaderTradeId: String? = null, // Leader 原始交易ID
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -83,9 +83,6 @@ data class CopyTrading(
|
||||
@Column(name = "max_position_value", precision = 20, scale = 8)
|
||||
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
|
||||
@Column(name = "max_position_count")
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
|
||||
// 关键字过滤配置
|
||||
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
|
||||
val keywordFilterMode: String = "DISABLED", // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
|
||||
@@ -231,7 +231,26 @@ enum class ErrorCode(
|
||||
SERVER_ORDER_TRACKING_PROCESS_FAILED(5901, "处理订单跟踪失败", "error.server.order_tracking_process_failed"),
|
||||
SERVER_ORDER_TRACKING_BUY_FAILED(5902, "处理买入订单失败", "error.server.order_tracking_buy_failed"),
|
||||
SERVER_ORDER_TRACKING_SELL_FAILED(5903, "处理卖出订单失败", "error.server.order_tracking_sell_failed"),
|
||||
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed");
|
||||
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed"),
|
||||
|
||||
// 回测服务错误 (4601-4699)
|
||||
BACKTEST_TASK_NOT_FOUND(4601, "回测任务不存在", "error.backtest.task_not_found"),
|
||||
BACKTEST_LEADER_NOT_FOUND(4602, "Leader不存在", "error.backtest.leader_not_found"),
|
||||
BACKTEST_DAYS_INVALID(4603, "回测天数超出限制", "error.backtest.days_invalid"),
|
||||
BACKTEST_INITIAL_BALANCE_INVALID(4604, "初始金额无效", "error.backtest.initial_balance_invalid"),
|
||||
BACKTEST_TASK_RUNNING(4605, "回测任务正在运行,无法删除", "error.backtest.task_running"),
|
||||
BACKTEST_TASK_NOT_COMPLETED(4606, "仅支持对已完成的回测任务重新测试", "error.backtest.task_not_completed"),
|
||||
SERVER_BACKTEST_CREATE_FAILED(5603, "创建回测任务失败", "error.server.backtest_create_failed"),
|
||||
SERVER_BACKTEST_UPDATE_FAILED(5604, "更新回测任务失败", "error.server.backtest_update_failed"),
|
||||
SERVER_BACKTEST_DELETE_FAILED(5605, "删除回测任务失败", "error.server.backtest_delete_failed"),
|
||||
SERVER_BACKTEST_LIST_FETCH_FAILED(5606, "查询回测列表失败", "error.server.backtest_list_fetch_failed"),
|
||||
SERVER_BACKTEST_DETAIL_FETCH_FAILED(5607, "查询回测详情失败", "error.server.backtest_detail_fetch_failed"),
|
||||
SERVER_BACKTEST_TRADES_FETCH_FAILED(5608, "查询回测交易记录失败", "error.server.backtest_trades_fetch_failed"),
|
||||
SERVER_BACKTEST_EXECUTE_FAILED(5609, "回测执行失败", "error.server.backtest_execute_failed"),
|
||||
SERVER_BACKTEST_HISTORICAL_DATA_FETCH_FAILED(5610, "历史数据获取失败", "error.server.backtest_historical_data_fetch_failed"),
|
||||
SERVER_BACKTEST_STOP_FAILED(5611, "停止回测任务失败", "error.server.backtest_stop_failed"),
|
||||
SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed"),
|
||||
SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_failed");
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.BacktestTask
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
/**
|
||||
* 回测任务Repository
|
||||
*/
|
||||
@Repository
|
||||
interface BacktestTaskRepository : JpaRepository<BacktestTask, Long> {
|
||||
|
||||
/**
|
||||
* 根据 Leader ID 查询回测任务
|
||||
*/
|
||||
fun findByLeaderId(leaderId: Long): List<BacktestTask>
|
||||
|
||||
/**
|
||||
* 根据状态查询回测任务
|
||||
*/
|
||||
fun findByStatus(status: String): List<BacktestTask>
|
||||
|
||||
/**
|
||||
* 根据 Leader ID 和状态查询回测任务
|
||||
*/
|
||||
fun findByLeaderIdAndStatus(leaderId: Long, status: String): List<BacktestTask>
|
||||
|
||||
/**
|
||||
* 根据 Leader ID、收益率排序查询
|
||||
*/
|
||||
@Query("SELECT t FROM BacktestTask t WHERE t.leaderId = :leaderId AND t.status = :status ORDER BY t.profitRate DESC")
|
||||
fun findByLeaderIdAndStatusOrderByProfitRateDesc(leaderId: Long, status: String): List<BacktestTask>
|
||||
|
||||
/**
|
||||
* 根据状态和创建时间倒序查询
|
||||
*/
|
||||
@Query("SELECT t FROM BacktestTask t WHERE t.status = :status ORDER BY t.createdAt DESC")
|
||||
fun findByStatusOrderByCreatedAtDesc(status: String): List<BacktestTask>
|
||||
|
||||
/**
|
||||
* 更新回测任务状态
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE BacktestTask t SET t.status = :status, t.updatedAt = :updatedAt WHERE t.id = :id")
|
||||
fun updateStatus(id: Long, status: String, updatedAt: Long = System.currentTimeMillis())
|
||||
|
||||
/**
|
||||
* 更新回测任务状态和错误信息
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE BacktestTask t SET t.status = :status, t.errorMessage = :errorMessage, t.updatedAt = :updatedAt WHERE t.id = :id")
|
||||
fun updateStatusAndError(id: Long, status: String, errorMessage: String?, updatedAt: Long = System.currentTimeMillis())
|
||||
|
||||
/**
|
||||
* 更新回测任务进度
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE BacktestTask t SET t.progress = :progress, t.updatedAt = :updatedAt WHERE t.id = :id")
|
||||
fun updateProgress(id: Long, progress: Int, updatedAt: Long = System.currentTimeMillis())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.BacktestTrade
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
/**
|
||||
* 回测交易记录Repository
|
||||
*/
|
||||
@Repository
|
||||
interface BacktestTradeRepository : JpaRepository<BacktestTrade, Long> {
|
||||
|
||||
/**
|
||||
* 根据回测任务ID查询所有交易记录
|
||||
*/
|
||||
fun findByBacktestTaskIdOrderByTradeTime(backtestTaskId: Long): List<BacktestTrade>
|
||||
|
||||
/**
|
||||
* 根据回测任务ID分页查询交易记录
|
||||
*/
|
||||
@Query("SELECT t FROM BacktestTrade t WHERE t.backtestTaskId = :backtestTaskId ORDER BY t.tradeTime")
|
||||
fun findByBacktestTaskId(
|
||||
backtestTaskId: Long,
|
||||
pageable: org.springframework.data.domain.Pageable
|
||||
): org.springframework.data.domain.Page<BacktestTrade>
|
||||
|
||||
/**
|
||||
* 根据回测任务ID统计交易数量
|
||||
*/
|
||||
fun countByBacktestTaskId(backtestTaskId: Long): Long
|
||||
|
||||
/**
|
||||
* 删除回测任务的所有交易记录(由级联删除处理)
|
||||
*/
|
||||
fun deleteByBacktestTaskId(backtestTaskId: Long)
|
||||
}
|
||||
|
||||
+4
-8
@@ -75,15 +75,11 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
|
||||
fun countActivePositions(copyTradingId: Long): Int
|
||||
|
||||
/**
|
||||
* 检查指定市场是否存在活跃仓位
|
||||
* 计算指定跟单配置、市场和方向下的当前持仓总价值 (成本价计算)
|
||||
* 按市场+方向(outcomeIndex)分别统计
|
||||
*/
|
||||
fun existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(copyTradingId: Long, marketId: String, remainingQuantity: BigDecimal): Boolean
|
||||
|
||||
/**
|
||||
* 计算指定跟单配置和市场下的当前持仓总价值 (成本价计算)
|
||||
*/
|
||||
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.remainingQuantity > 0")
|
||||
fun sumCurrentPositionValueByMarket(copyTradingId: Long, marketId: String): BigDecimal?
|
||||
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.outcomeIndex = :outcomeIndex AND t.remainingQuantity > 0")
|
||||
fun sumCurrentPositionValueByMarketAndOutcomeIndex(copyTradingId: Long, marketId: String, outcomeIndex: Int): BigDecimal?
|
||||
|
||||
/**
|
||||
* 查询指定跟单配置下,创建时间超过指定时间点的未匹配订单(FIFO顺序)
|
||||
|
||||
+234
-88
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息
|
||||
*/
|
||||
@@ -278,7 +468,7 @@ class AccountService(
|
||||
if (accountId == null) {
|
||||
return Result.failure(IllegalArgumentException("账户ID不能为空"))
|
||||
}
|
||||
|
||||
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
@@ -288,68 +478,19 @@ class AccountService(
|
||||
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
|
||||
}
|
||||
|
||||
// 查询 USDC 余额和持仓信息
|
||||
// 使用通用方法查询余额
|
||||
val balanceResult = runBlocking {
|
||||
try {
|
||||
// 查询持仓信息(用于返回持仓列表)
|
||||
// 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓)
|
||||
val positionsResult = blockchainService.getPositions(account.proxyAddress)
|
||||
val positions = if (positionsResult.isSuccess) {
|
||||
positionsResult.getOrNull()?.map { pos ->
|
||||
PositionDto(
|
||||
marketId = pos.conditionId ?: "",
|
||||
side = pos.outcome ?: "",
|
||||
quantity = pos.size?.toString() ?: "0",
|
||||
avgPrice = pos.avgPrice?.toString() ?: "0",
|
||||
currentValue = pos.currentValue?.toString() ?: "0",
|
||||
pnl = pos.cashPnl?.toString()
|
||||
)
|
||||
} ?: emptyList()
|
||||
} else {
|
||||
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// 使用 /value 接口获取仓位总价值(而不是累加)
|
||||
val positionBalanceResult = blockchainService.getTotalValue(account.proxyAddress)
|
||||
val positionBalance = if (positionBalanceResult.isSuccess) {
|
||||
positionBalanceResult.getOrNull() ?: "0"
|
||||
} else {
|
||||
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
|
||||
"0"
|
||||
}
|
||||
|
||||
// 查询可用余额(通过 RPC 查询 USDC 余额)
|
||||
// 必须使用代理地址查询
|
||||
val availableBalanceResult = blockchainService.getUsdcBalance(
|
||||
walletAddress = account.walletAddress,
|
||||
proxyAddress = account.proxyAddress
|
||||
)
|
||||
val availableBalance = if (availableBalanceResult.isSuccess) {
|
||||
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
|
||||
} else {
|
||||
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
|
||||
val error = availableBalanceResult.exceptionOrNull()
|
||||
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
|
||||
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
|
||||
}
|
||||
|
||||
// 计算总余额 = 可用余额 + 仓位余额
|
||||
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
|
||||
|
||||
AccountBalanceResponse(
|
||||
availableBalance = availableBalance,
|
||||
positionBalance = positionBalance,
|
||||
totalBalance = totalBalance.toPlainString(),
|
||||
positions = positions
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询余额失败: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
blockchainService.getWalletBalance(account.proxyAddress)
|
||||
}
|
||||
|
||||
Result.success(balanceResult)
|
||||
balanceResult.map { walletBalance: WalletBalanceResponse ->
|
||||
AccountBalanceResponse(
|
||||
availableBalance = walletBalance.availableBalance,
|
||||
positionBalance = walletBalance.positionBalance,
|
||||
totalBalance = walletBalance.totalBalance,
|
||||
positions = walletBalance.positions
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询账户余额失败", e)
|
||||
Result.failure(e)
|
||||
@@ -876,7 +1017,7 @@ class AccountService(
|
||||
"0"
|
||||
}
|
||||
|
||||
// 11. 创建并签名订单(使用计算后的卖出数量)
|
||||
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType)
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedPrivateKey,
|
||||
@@ -885,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
|
||||
@@ -1238,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("赎回仓位列表不能为空"))
|
||||
}
|
||||
@@ -1266,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>>()
|
||||
@@ -1309,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
|
||||
|
||||
@@ -1329,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(
|
||||
@@ -1364,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!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package com.wrbug.polymarketbot.service.backtest
|
||||
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.UserActivityResponse
|
||||
import com.wrbug.polymarketbot.dto.TradeData
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.delay
|
||||
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 历史交易,使用 start 游标分页(避免 offset 过大报错)
|
||||
*/
|
||||
@Service
|
||||
class BacktestDataService(
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(BacktestDataService::class.java)
|
||||
|
||||
/**
|
||||
* 按 start 游标获取一批 Leader 历史交易
|
||||
* 规则:limit 固定为 500;若返回 500 条则取本批最大时间戳(秒)作为下一页 start,不加 1(同一秒可能多笔订单,由下游按 tradeId 去重);不足 500 则为最后一页
|
||||
*
|
||||
* @param leaderId Leader ID
|
||||
* @param startTime 回测开始时间(毫秒)
|
||||
* @param endTime 回测结束时间(毫秒)
|
||||
* @param cursorStartSeconds 本页游标(API 的 start,秒);首次传 startTime/1000
|
||||
* @param limit 每批条数,建议 500
|
||||
* @return 本批交易与下一页游标(null 表示没有下一页)
|
||||
*/
|
||||
suspend fun getLeaderHistoricalTradesBatch(
|
||||
leaderId: Long,
|
||||
startTime: Long,
|
||||
endTime: Long,
|
||||
cursorStartSeconds: Long,
|
||||
limit: Int
|
||||
): LeaderTradesBatchResult {
|
||||
logger.info("获取 Leader 历史交易批次: leaderId=$leaderId, cursorStart=$cursorStartSeconds, limit=$limit")
|
||||
|
||||
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||
?: throw IllegalArgumentException("Leader 不存在: $leaderId")
|
||||
|
||||
val dataApi = retrofitFactory.createDataApi()
|
||||
val endSeconds = endTime / 1000
|
||||
val maxRetries = 5
|
||||
val retryDelay = 1000L
|
||||
|
||||
var lastException: Exception? = null
|
||||
for (attempt in 1..maxRetries) {
|
||||
try {
|
||||
val response = dataApi.getUserActivity(
|
||||
user = leader.leaderAddress,
|
||||
type = listOf("TRADE"),
|
||||
start = cursorStartSeconds,
|
||||
end = endSeconds,
|
||||
limit = limit,
|
||||
offset = null,
|
||||
sortBy = "TIMESTAMP",
|
||||
sortDirection = "ASC"
|
||||
)
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
throw Exception("从 Data API 获取用户活动失败: code=${response.code()}, message=${response.message()}")
|
||||
}
|
||||
|
||||
val activities = response.body()!!
|
||||
logger.info("本批获取 ${activities.size} 条活动(第 $attempt 次尝试)")
|
||||
|
||||
val trades = activities.mapNotNull { activity ->
|
||||
try {
|
||||
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")
|
||||
return@mapNotNull null
|
||||
}
|
||||
TradeData(
|
||||
tradeId = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}_${activity.side}",
|
||||
marketId = activity.conditionId,
|
||||
marketTitle = activity.title,
|
||||
marketSlug = activity.slug,
|
||||
side = activity.side.uppercase(),
|
||||
outcome = activity.outcome ?: activity.outcomeIndex?.toString() ?: "",
|
||||
outcomeIndex = activity.outcomeIndex,
|
||||
price = activity.price.toSafeBigDecimal(),
|
||||
size = activity.size.toSafeBigDecimal(),
|
||||
amount = activity.usdcSize.toSafeBigDecimal(),
|
||||
timestamp = tradeTimestamp
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("转换活动数据失败: activity=$activity, error=${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// 下一页 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 次获取批次失败: ${e.message}")
|
||||
if (attempt < maxRetries) {
|
||||
logger.info("等待 $retryDelay 毫秒后重试...")
|
||||
delay(retryDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
val errorMsg = "重试 $maxRetries 次后仍然失败,cursorStart=$cursorStartSeconds"
|
||||
logger.error(errorMsg, lastException)
|
||||
throw Exception(errorMsg, lastException)
|
||||
}
|
||||
}
|
||||
+792
@@ -0,0 +1,792 @@
|
||||
package com.wrbug.polymarketbot.service.backtest
|
||||
|
||||
import com.wrbug.polymarketbot.dto.TradeData
|
||||
import com.wrbug.polymarketbot.dto.BacktestStatisticsDto
|
||||
import com.wrbug.polymarketbot.entity.BacktestTask
|
||||
import com.wrbug.polymarketbot.entity.BacktestTrade
|
||||
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
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Service
|
||||
class BacktestExecutionService(
|
||||
private val backtestTaskRepository: BacktestTaskRepository,
|
||||
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,
|
||||
val outcome: String,
|
||||
val outcomeIndex: Int?,
|
||||
var quantity: BigDecimal,
|
||||
val avgPrice: BigDecimal,
|
||||
val leaderBuyQuantity: BigDecimal?,
|
||||
val marketEndDate: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 将回测任务转换为虚拟的 CopyTrading 配置用于执行
|
||||
* 注意:回测场景使用历史数据,不需要实时跟单的相关配置
|
||||
*/
|
||||
private fun taskToCopyTrading(task: BacktestTask): CopyTrading {
|
||||
return CopyTrading(
|
||||
id = task.id,
|
||||
accountId = 0L,
|
||||
leaderId = task.leaderId,
|
||||
enabled = true,
|
||||
copyMode = task.copyMode,
|
||||
copyRatio = task.copyRatio,
|
||||
fixedAmount = null,
|
||||
maxOrderSize = task.maxOrderSize,
|
||||
minOrderSize = task.minOrderSize,
|
||||
maxDailyLoss = task.maxDailyLoss,
|
||||
maxDailyOrders = task.maxDailyOrders,
|
||||
priceTolerance = BigDecimal.ZERO, // 回测使用历史价格,不需要容忍度
|
||||
delaySeconds = 0, // 回测按时间线执行,无需延迟
|
||||
pollIntervalSeconds = 5,
|
||||
useWebSocket = false,
|
||||
websocketReconnectInterval = 5000,
|
||||
websocketMaxRetries = 10,
|
||||
supportSell = task.supportSell,
|
||||
minOrderDepth = null, // 回测无实时订单簿数据
|
||||
maxSpread = null, // 回测无实时价差数据
|
||||
maxPositionValue = task.maxPositionValue,
|
||||
minPrice = task.minPrice, // 最低价格
|
||||
maxPrice = task.maxPrice, // 最高价格
|
||||
keywordFilterMode = task.keywordFilterMode,
|
||||
keywords = task.keywords,
|
||||
configName = null,
|
||||
pushFailedOrders = false,
|
||||
pushFilteredOrders = false,
|
||||
createdAt = task.createdAt,
|
||||
updatedAt = task.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行回测任务(支持分页和恢复)
|
||||
* 自动处理所有页面的数据,支持中断恢复
|
||||
*/
|
||||
/** 每批请求 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}, batchLimit=$backtestBatchLimit")
|
||||
|
||||
// 1. 更新任务状态为 RUNNING
|
||||
task.status = "RUNNING"
|
||||
task.executionStartedAt = System.currentTimeMillis()
|
||||
task.updatedAt = System.currentTimeMillis()
|
||||
backtestTaskRepository.save(task)
|
||||
|
||||
// 2. 初始化
|
||||
var currentBalance = task.initialBalance
|
||||
val positions = mutableMapOf<String, Position>()
|
||||
val trades = mutableListOf<BacktestTrade>()
|
||||
val dailyOrderCountCache = mutableMapOf<String, Int>()
|
||||
val dailyLossCache = mutableMapOf<String, BigDecimal>()
|
||||
val seenTradeIds = mutableSetOf<String>()
|
||||
|
||||
// 3. 回测时间范围:首次执行以当前时间为基准取最近 backtestDays 天;断点续跑保留原 startTime,仅 endTime 延到当前
|
||||
val endTime = System.currentTimeMillis()
|
||||
val startTime = if (task.lastProcessedTradeTime == null) {
|
||||
endTime - (task.backtestDays * 24L * 3600 * 1000)
|
||||
} else {
|
||||
task.startTime
|
||||
}
|
||||
|
||||
logger.info("回测时间范围: ${formatTimestamp(startTime)} - ${formatTimestamp(endTime)} (${task.backtestDays} 天), " +
|
||||
"初始余额: ${task.initialBalance.toPlainString()}")
|
||||
|
||||
// 4. 游标分页:恢复时也从 lastProcessedTradeTime 所在秒开始拉(不加 1),与分页规则一致;已处理的通过 timestamp 跳过,不依赖内存 seenTradeIds
|
||||
var cursorSeconds = if (task.lastProcessedTradeTime != null) {
|
||||
task.lastProcessedTradeTime!! / 1000
|
||||
} else {
|
||||
startTime / 1000
|
||||
}
|
||||
val endSeconds = endTime / 1000
|
||||
val resumeThresholdMs = task.lastProcessedTradeTime ?: 0L
|
||||
|
||||
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("正在获取批次数据 cursorStart=$cursorSeconds (${formatTimestamp(cursorSeconds * 1000)}) ...")
|
||||
|
||||
val currentPageTrades = mutableListOf<BacktestTrade>()
|
||||
|
||||
try {
|
||||
val batch = backtestDataService.getLeaderHistoricalTradesBatch(
|
||||
task.leaderId,
|
||||
startTime,
|
||||
endTime,
|
||||
cursorSeconds,
|
||||
backtestBatchLimit
|
||||
)
|
||||
val pageTrades = batch.trades
|
||||
|
||||
if (pageTrades.isEmpty()) {
|
||||
logger.info("本批无数据,所有数据处理完成")
|
||||
break
|
||||
}
|
||||
|
||||
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]
|
||||
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++
|
||||
|
||||
// 进度按时间比例:(当前订单时间 - 开始时间) / (结束时间 - 开始时间) * 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
|
||||
}
|
||||
if (progress > task.progress) {
|
||||
task.progress = progress
|
||||
task.processedTradeCount = index + 1
|
||||
backtestTaskRepository.save(task)
|
||||
}
|
||||
|
||||
try {
|
||||
// 5.1 实时检查并结算已到期的市场
|
||||
currentBalance = settleExpiredPositions(task, positions, currentBalance, trades, leaderTrade.timestamp, currentPageTrades)
|
||||
|
||||
// 5.2 检查余额和持仓状态
|
||||
if (currentBalance <= BigDecimal.ONE) {
|
||||
logger.info(
|
||||
if (currentBalance < BigDecimal.ZERO) "余额已为负,直接终止回测: $currentBalance"
|
||||
else "余额<=1,停止回测: $currentBalance"
|
||||
)
|
||||
terminateBacktest = true
|
||||
break
|
||||
}
|
||||
|
||||
// 5.3 应用过滤规则
|
||||
val copyTrading = taskToCopyTrading(task)
|
||||
val filterResult = copyTradingFilterService.checkFilters(
|
||||
copyTrading,
|
||||
tokenId = "",
|
||||
tradePrice = leaderTrade.price,
|
||||
copyOrderAmount = null,
|
||||
marketId = leaderTrade.marketId,
|
||||
marketTitle = leaderTrade.marketTitle,
|
||||
marketEndDate = null,
|
||||
outcomeIndex = leaderTrade.outcomeIndex
|
||||
)
|
||||
|
||||
if (!filterResult.isPassed) {
|
||||
logger.debug("交易被过滤: ${leaderTrade.tradeId}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 5.4 每日订单数检查 - 使用缓存,只统计 BUY 订单
|
||||
val tradeDate = formatDate(leaderTrade.timestamp)
|
||||
val dailyOrderCount = dailyOrderCountCache.getOrDefault(tradeDate, 0)
|
||||
|
||||
if (dailyOrderCount >= task.maxDailyOrders) {
|
||||
logger.info("已达到每日最大 BUY 订单数限制: $dailyOrderCount / ${task.maxDailyOrders}")
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// 5.6 计算跟单金额
|
||||
val followAmount = calculateFollowAmount(task, leaderTrade)
|
||||
|
||||
// 5.6.1 检查订单大小限制
|
||||
val finalFollowAmount = if (followAmount > task.maxOrderSize) {
|
||||
logger.info("跟单金额超过最大限制: $followAmount > ${task.maxOrderSize},调整为最大值")
|
||||
task.maxOrderSize
|
||||
} else if (followAmount < task.minOrderSize) {
|
||||
logger.info("跟单金额低于最小限制: $followAmount < ${task.minOrderSize},调整为最小值")
|
||||
task.minOrderSize
|
||||
} else {
|
||||
followAmount
|
||||
}
|
||||
|
||||
// 5.6.2 检查每日最大亏损(买入订单)- 使用缓存
|
||||
val dailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
|
||||
if (dailyLoss > task.maxDailyLoss) {
|
||||
logger.info("已达到每日最大亏损限制: $dailyLoss / ${task.maxDailyLoss},跳过买入订单")
|
||||
continue
|
||||
}
|
||||
|
||||
// 5.7 处理买卖逻辑
|
||||
if (leaderTrade.side == "BUY") {
|
||||
// 余额不足时按最大可用余额交易,仍须满足最小订单金额
|
||||
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}"
|
||||
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(
|
||||
backtestTaskId = task.id!!,
|
||||
tradeTime = leaderTrade.timestamp,
|
||||
marketId = leaderTrade.marketId,
|
||||
marketTitle = leaderTrade.marketTitle,
|
||||
side = "BUY",
|
||||
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
|
||||
outcomeIndex = leaderTrade.outcomeIndex,
|
||||
quantity = quantity,
|
||||
price = leaderTrade.price.toSafeBigDecimal(),
|
||||
amount = actualBuyAmount,
|
||||
fee = BigDecimal.ZERO,
|
||||
profitLoss = null,
|
||||
balanceAfter = currentBalance,
|
||||
leaderTradeId = leaderTrade.tradeId
|
||||
))
|
||||
|
||||
// 更新每日订单数缓存
|
||||
dailyOrderCountCache[tradeDate] = dailyOrderCount + 1
|
||||
|
||||
} else {
|
||||
// SELL 逻辑
|
||||
if (!task.supportSell) {
|
||||
continue
|
||||
}
|
||||
|
||||
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
|
||||
val position = positions[positionKey] ?: continue
|
||||
|
||||
// 计算卖出数量
|
||||
val sellQuantity = if (task.copyMode == "RATIO") {
|
||||
if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
|
||||
position.quantity.multiply(
|
||||
leaderTrade.size.divide(position.leaderBuyQuantity, 8, java.math.RoundingMode.DOWN)
|
||||
)
|
||||
} else {
|
||||
position.quantity
|
||||
}
|
||||
} else {
|
||||
position.quantity
|
||||
}
|
||||
|
||||
val actualSellQuantity = if (sellQuantity > position.quantity) {
|
||||
position.quantity
|
||||
} else {
|
||||
sellQuantity
|
||||
}
|
||||
|
||||
// 计算卖出金额
|
||||
val sellAmount = actualSellQuantity.multiply(leaderTrade.price.toSafeBigDecimal())
|
||||
|
||||
// 5.6.2 检查卖出金额限制
|
||||
val finalSellAmount = if (sellAmount > task.maxOrderSize) {
|
||||
logger.info("卖出金额超过最大限制: $sellAmount > ${task.maxOrderSize},调整为最大值")
|
||||
task.maxOrderSize
|
||||
} else if (sellAmount < task.minOrderSize) {
|
||||
logger.info("卖出金额低于最小限制: $sellAmount < ${task.minOrderSize},调整为最小值")
|
||||
task.minOrderSize
|
||||
} else {
|
||||
sellAmount
|
||||
}
|
||||
|
||||
val netAmount = finalSellAmount
|
||||
|
||||
// 计算盈亏
|
||||
val cost = actualSellQuantity.multiply(position.avgPrice)
|
||||
val profitLoss = netAmount.subtract(cost)
|
||||
|
||||
// 更新余额和持仓
|
||||
currentBalance += netAmount
|
||||
if (position.quantity <= BigDecimal.ZERO) {
|
||||
positions.remove(positionKey)
|
||||
}
|
||||
|
||||
// 记录交易到当前页列表
|
||||
currentPageTrades.add(BacktestTrade(
|
||||
backtestTaskId = task.id!!,
|
||||
tradeTime = leaderTrade.timestamp,
|
||||
marketId = leaderTrade.marketId,
|
||||
marketTitle = leaderTrade.marketTitle,
|
||||
side = "SELL",
|
||||
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
|
||||
outcomeIndex = leaderTrade.outcomeIndex,
|
||||
quantity = actualSellQuantity,
|
||||
price = leaderTrade.price.toSafeBigDecimal(),
|
||||
amount = finalSellAmount,
|
||||
fee = BigDecimal.ZERO,
|
||||
profitLoss = profitLoss,
|
||||
balanceAfter = currentBalance,
|
||||
leaderTradeId = leaderTrade.tradeId
|
||||
))
|
||||
// SELL 订单不计入每日订单数限制
|
||||
|
||||
// 更新每日亏损缓存(只累加亏损,不累加盈利)
|
||||
if (profitLoss < BigDecimal.ZERO) {
|
||||
val currentDailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
|
||||
dailyLossCache[tradeDate] = currentDailyLoss + profitLoss.negate()
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理交易失败: tradeId=${leaderTrade.tradeId}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存本批交易
|
||||
if (currentPageTrades.isNotEmpty()) {
|
||||
logger.info("保存本批交易,共 ${currentPageTrades.size} 笔")
|
||||
backtestTradeRepository.saveAll(currentPageTrades)
|
||||
|
||||
val lastTradeInPage = currentPageTrades.lastOrNull()
|
||||
if (lastTradeInPage != null && lastProcessedIndexInPage != null) {
|
||||
task.lastProcessedTradeTime = lastTradeInPage.tradeTime
|
||||
task.lastProcessedTradeIndex = lastProcessedIndexInPage
|
||||
task.processedTradeCount = lastProcessedIndexInPage + 1
|
||||
task.finalBalance = currentBalance
|
||||
backtestTaskRepository.save(task)
|
||||
logger.info("本批处理完成,lastProcessedTradeIndex=${task.lastProcessedTradeIndex}, 总处理数=${task.processedTradeCount}")
|
||||
}
|
||||
} else {
|
||||
logger.info("本批没有交易需要保存")
|
||||
}
|
||||
|
||||
trades.addAll(currentPageTrades)
|
||||
|
||||
if (batch.nextCursorSeconds == null) {
|
||||
logger.info("本批不足 $backtestBatchLimit 条,已是最后一页")
|
||||
break
|
||||
}
|
||||
cursorSeconds = batch.nextCursorSeconds!!
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取或处理本批数据失败: ${e.message}", e)
|
||||
// 重试失败,标记任务为 FAILED
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 处理回测结束时仍未到期的持仓
|
||||
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)
|
||||
|
||||
// 8. 更新任务状态
|
||||
val profitAmount = currentBalance.subtract(task.initialBalance)
|
||||
val profitRate = if (task.initialBalance > BigDecimal.ZERO) {
|
||||
profitAmount.divide(task.initialBalance, 4, java.math.RoundingMode.HALF_UP).multiply(BigDecimal("100"))
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
val finalStatus = if (task.status == "STOPPED") "STOPPED" else "COMPLETED"
|
||||
|
||||
task.finalBalance = currentBalance
|
||||
task.profitAmount = profitAmount
|
||||
task.profitRate = profitRate
|
||||
task.endTime = endTime
|
||||
task.status = finalStatus
|
||||
task.progress = 100
|
||||
task.totalTrades = trades.size
|
||||
task.buyTrades = trades.count { it.side == "BUY" }
|
||||
task.sellTrades = trades.count { it.side == "SELL" }
|
||||
task.winTrades = statistics.winTrades
|
||||
task.lossTrades = statistics.lossTrades
|
||||
task.winRate = statistics.winRate.toSafeBigDecimal()
|
||||
task.maxProfit = statistics.maxProfit.toSafeBigDecimal()
|
||||
task.maxLoss = statistics.maxLoss.toSafeBigDecimal()
|
||||
task.maxDrawdown = statistics.maxDrawdown.toSafeBigDecimal()
|
||||
task.avgHoldingTime = statistics.avgHoldingTime
|
||||
task.executionFinishedAt = System.currentTimeMillis()
|
||||
task.updatedAt = System.currentTimeMillis()
|
||||
|
||||
backtestTaskRepository.save(task)
|
||||
|
||||
logger.info("回测任务执行完成: taskId=${task.id}, " +
|
||||
"最终余额=${currentBalance.toPlainString()}, " +
|
||||
"收益额=${task.profitAmount?.toPlainString()}, " +
|
||||
"收益率=${task.profitRate?.toPlainString()}%, " +
|
||||
"总交易数=${trades.size}, " +
|
||||
"盈利率=${task.winRate?.toPlainString()}%")
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("回测任务执行失败: taskId=${task.id}", e)
|
||||
task.status = "FAILED"
|
||||
task.errorMessage = e.message
|
||||
task.executionFinishedAt = System.currentTimeMillis()
|
||||
task.updatedAt = System.currentTimeMillis()
|
||||
backtestTaskRepository.save(task)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算已到期的市场
|
||||
* @param batchTradesToSave 本批要持久化的交易列表,到期结算(赎回/输)会追加到此列表并随本批一起落库
|
||||
*/
|
||||
private suspend fun settleExpiredPositions(
|
||||
task: BacktestTask,
|
||||
positions: MutableMap<String, Position>,
|
||||
currentBalance: BigDecimal,
|
||||
trades: MutableList<BacktestTrade>,
|
||||
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,
|
||||
position.outcomeIndex ?: 0
|
||||
)
|
||||
|
||||
val price = marketPrice.toSafeBigDecimal()
|
||||
|
||||
// 通过市场价格判断结算价格
|
||||
val settlementPrice = when {
|
||||
price >= BigDecimal("0.95") -> BigDecimal.ONE
|
||||
price <= BigDecimal("0.05") -> BigDecimal.ZERO
|
||||
else -> position.avgPrice
|
||||
}
|
||||
|
||||
val settlementValue = position.quantity.multiply(settlementPrice)
|
||||
val profitLoss = settlementValue.subtract(position.quantity.multiply(position.avgPrice))
|
||||
|
||||
balance += settlementValue
|
||||
|
||||
val marketTitle = marketService.getMarket(position.marketId)?.title ?: ""
|
||||
val settlementTrade = BacktestTrade(
|
||||
backtestTaskId = task.id!!,
|
||||
tradeTime = currentTime,
|
||||
marketId = position.marketId,
|
||||
marketTitle = marketTitle,
|
||||
side = "SETTLEMENT",
|
||||
outcome = when {
|
||||
settlementPrice == BigDecimal.ONE -> "WIN"
|
||||
settlementPrice == BigDecimal.ZERO -> "LOSE"
|
||||
else -> "UNKNOWN"
|
||||
},
|
||||
outcomeIndex = position.outcomeIndex,
|
||||
quantity = position.quantity,
|
||||
price = settlementPrice,
|
||||
amount = settlementValue,
|
||||
fee = BigDecimal.ZERO,
|
||||
profitLoss = profitLoss,
|
||||
balanceAfter = balance,
|
||||
leaderTradeId = null
|
||||
)
|
||||
trades.add(settlementTrade)
|
||||
batchTradesToSave.add(settlementTrade)
|
||||
|
||||
// 移除已结算的持仓
|
||||
positions.remove(positionKey)
|
||||
} catch (e: Exception) {
|
||||
logger.error("结算市场失败: marketId=${position.marketId}, outcomeIndex=${position.outcomeIndex}", e)
|
||||
}
|
||||
}
|
||||
|
||||
return balance
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算未到期持仓(回测结束时剩余持仓按均价平仓)
|
||||
* @param settlementsToSave 本批结算记录会追加到此列表,调用方需落库
|
||||
*/
|
||||
private suspend fun settleRemainingPositions(
|
||||
task: BacktestTask,
|
||||
positions: MutableMap<String, Position>,
|
||||
currentBalance: BigDecimal,
|
||||
trades: MutableList<BacktestTrade>,
|
||||
currentTime: Long,
|
||||
settlementsToSave: MutableList<BacktestTrade>
|
||||
): BigDecimal {
|
||||
var balance = currentBalance
|
||||
|
||||
for ((positionKey, position) in positions.toList()) {
|
||||
val quantity = position.quantity
|
||||
val avgPrice = position.avgPrice
|
||||
val settlementPrice = avgPrice
|
||||
|
||||
val settlementValue = quantity.multiply(settlementPrice)
|
||||
val profitLoss = settlementValue.negate()
|
||||
|
||||
balance += settlementValue
|
||||
|
||||
val marketTitle = marketService.getMarket(position.marketId)?.title ?: ""
|
||||
val closedTrade = BacktestTrade(
|
||||
backtestTaskId = task.id!!,
|
||||
tradeTime = currentTime,
|
||||
marketId = position.marketId,
|
||||
marketTitle = marketTitle,
|
||||
side = "SETTLEMENT",
|
||||
outcome = "CLOSED",
|
||||
outcomeIndex = position.outcomeIndex,
|
||||
quantity = quantity,
|
||||
price = avgPrice,
|
||||
amount = settlementValue,
|
||||
fee = BigDecimal.ZERO,
|
||||
profitLoss = profitLoss,
|
||||
balanceAfter = balance,
|
||||
leaderTradeId = null
|
||||
)
|
||||
trades.add(closedTrade)
|
||||
settlementsToSave.add(closedTrade)
|
||||
}
|
||||
|
||||
positions.clear()
|
||||
return balance
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算统计数据
|
||||
*/
|
||||
private fun calculateStatistics(trades: List<BacktestTrade>): BacktestStatisticsDto {
|
||||
val buyTrades = trades.count { it.side == "BUY" }
|
||||
val sellTrades = trades.count { it.side == "SELL" }
|
||||
val winTrades = trades.count { it.profitLoss != null && it.profitLoss > BigDecimal.ZERO }
|
||||
val lossTrades = trades.count { it.profitLoss != null && it.profitLoss < BigDecimal.ZERO }
|
||||
|
||||
var totalProfit = BigDecimal.ZERO
|
||||
var totalLoss = BigDecimal.ZERO
|
||||
var maxProfit = BigDecimal.ZERO
|
||||
var maxLoss = BigDecimal.ZERO
|
||||
|
||||
// 计算最大回撤
|
||||
var runningBalance = if (trades.isNotEmpty()) {
|
||||
trades[0].balanceAfter?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
var peakBalance = runningBalance
|
||||
var maxDrawdown = BigDecimal.ZERO
|
||||
|
||||
for (i in trades.indices) {
|
||||
val trade = trades[i]
|
||||
val balance = trade.balanceAfter?.toSafeBigDecimal() ?: continue
|
||||
|
||||
if (trade.profitLoss != null) {
|
||||
val pnl = trade.profitLoss.toSafeBigDecimal()
|
||||
if (pnl > BigDecimal.ZERO) {
|
||||
totalProfit += pnl
|
||||
if (pnl > maxProfit) maxProfit = pnl
|
||||
} else {
|
||||
totalLoss += pnl
|
||||
if (pnl < maxLoss) maxLoss = pnl
|
||||
}
|
||||
}
|
||||
|
||||
if (balance > peakBalance) {
|
||||
peakBalance = balance
|
||||
}
|
||||
val drawdown = peakBalance - runningBalance
|
||||
if (drawdown > maxDrawdown) {
|
||||
maxDrawdown = drawdown
|
||||
}
|
||||
|
||||
runningBalance = balance
|
||||
}
|
||||
|
||||
// 计算平均持仓时间
|
||||
var avgHoldingTime: Long? = null
|
||||
if (trades.size > 1) {
|
||||
var totalHoldingTime = 0L
|
||||
var count = 0
|
||||
for (i in 0 until trades.size - 1) {
|
||||
val currentTrade = trades[i]
|
||||
val nextTrade = trades[i + 1]
|
||||
|
||||
if (currentTrade.side == "BUY" && nextTrade.side == "SELL") {
|
||||
val holdingTime = nextTrade.tradeTime - currentTrade.tradeTime
|
||||
totalHoldingTime += holdingTime
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
avgHoldingTime = totalHoldingTime / count
|
||||
}
|
||||
}
|
||||
|
||||
return BacktestStatisticsDto(
|
||||
totalTrades = trades.size,
|
||||
buyTrades = buyTrades,
|
||||
sellTrades = sellTrades,
|
||||
winTrades = winTrades,
|
||||
lossTrades = lossTrades,
|
||||
winRate = if (buyTrades + sellTrades > 0) {
|
||||
(winTrades.toBigDecimal().divide((buyTrades + sellTrades).toBigDecimal(), 4, java.math.RoundingMode.HALF_UP))
|
||||
.multiply(BigDecimal("100"))
|
||||
.toPlainString()
|
||||
} else {
|
||||
BigDecimal.ZERO.toPlainString()
|
||||
},
|
||||
maxProfit = maxProfit.toPlainString(),
|
||||
maxLoss = maxLoss.toPlainString(),
|
||||
maxDrawdown = maxDrawdown.toPlainString(),
|
||||
avgHoldingTime = avgHoldingTime
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算跟单金额
|
||||
*/
|
||||
private fun calculateFollowAmount(task: BacktestTask, leaderTrade: TradeData): BigDecimal {
|
||||
return if (task.copyMode == "RATIO") {
|
||||
// 比例模式:Leader 成交金额 × 跟单比例
|
||||
leaderTrade.amount.toSafeBigDecimal().multiply(task.copyRatio)
|
||||
} else {
|
||||
// 固定金额模式:使用配置的固定金额
|
||||
task.fixedAmount ?: leaderTrade.amount.toSafeBigDecimal()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否同一天
|
||||
*/
|
||||
private fun isSameDay(timestamp1: Long, timestamp2: Long): Boolean {
|
||||
val cal1 = Calendar.getInstance().apply { timeInMillis = timestamp1 }
|
||||
val cal2 = Calendar.getInstance().apply { timeInMillis = timestamp2 }
|
||||
return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) &&
|
||||
cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间戳
|
||||
*/
|
||||
private fun formatTimestamp(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期(用于缓存key)
|
||||
*/
|
||||
private fun formatDate(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd")
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package com.wrbug.polymarketbot.service.backtest
|
||||
|
||||
import com.wrbug.polymarketbot.entity.BacktestTask
|
||||
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* 回测轮询服务
|
||||
* 定时获取待执行的回测任务并执行
|
||||
*/
|
||||
@Service
|
||||
class BacktestPollingService(
|
||||
private val backtestTaskRepository: BacktestTaskRepository,
|
||||
private val executionService: BacktestExecutionService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(BacktestPollingService::class.java)
|
||||
|
||||
// 线程池:同一时刻只执行一个任务
|
||||
private val executor: ExecutorService = Executors.newFixedThreadPool(1) as ThreadPoolExecutor
|
||||
|
||||
/**
|
||||
* 轮询待执行的回测任务
|
||||
* 每 10 秒执行一次
|
||||
* 规则:同一时刻只执行一个任务,如果有多个待执行任务,按创建时间先后执行最早创建的
|
||||
*/
|
||||
@Scheduled(fixedDelay = 10000) // 10 秒
|
||||
fun pollPendingTasks() {
|
||||
try {
|
||||
// 1. 检查是否有长时间处于 RUNNING 状态的任务(可能是应用重启导致的)
|
||||
val runningTasks = backtestTaskRepository.findByStatus("RUNNING")
|
||||
if (runningTasks.isNotEmpty()) {
|
||||
val activeQueueSize = (executor as ThreadPoolExecutor).queue.size
|
||||
val activeCount = (executor as ThreadPoolExecutor).activeCount
|
||||
|
||||
// 如果有线程池中没有活跃任务但有 RUNNING 状态的任务,说明是应用重启导致的
|
||||
// 重置这些任务的状态为 PENDING,以便恢复执行
|
||||
if (activeCount == 0 && runningTasks.isNotEmpty()) {
|
||||
logger.info("检测到应用重启导致的异常 RUNNING 任务,重置为 PENDING 以便恢复")
|
||||
runningTasks.forEach { task ->
|
||||
val now = System.currentTimeMillis()
|
||||
val executionStartedAt = task.executionStartedAt
|
||||
val executionDuration = if (executionStartedAt != null) {
|
||||
now - executionStartedAt
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
|
||||
// 如果任务执行时间超过 1 分钟,认为是异常状态
|
||||
if (executionDuration > 60000) {
|
||||
logger.info("重置异常 RUNNING 任务: taskId=${task.id}, executionStartedAt=$executionStartedAt, duration=${executionDuration}ms")
|
||||
task.status = "PENDING"
|
||||
task.updatedAt = now
|
||||
backtestTaskRepository.save(task)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug("有 ${runningTasks.size} 个任务正在执行,跳过本次轮询")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查询所有 PENDING 状态的任务,按创建时间升序排序
|
||||
val pendingTasks = backtestTaskRepository.findByStatus("PENDING")
|
||||
.sortedBy { it.createdAt }
|
||||
|
||||
if (pendingTasks.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 只执行最早创建的任务
|
||||
val taskToExecute = pendingTasks.first()
|
||||
logger.info("找到 ${pendingTasks.size} 个待执行的回测任务,执行最早创建的任务: taskId=${taskToExecute.id}, createdAt=${taskToExecute.createdAt}")
|
||||
|
||||
// 4. 提交任务到线程池执行
|
||||
executor.submit {
|
||||
try {
|
||||
// 执行前再次检查任务状态(防止并发执行)
|
||||
val currentTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null)
|
||||
if (currentTask == null || currentTask.status != "PENDING") {
|
||||
logger.debug("任务状态已变更,跳过执行: taskId=${taskToExecute.id}, currentStatus=${currentTask?.status}")
|
||||
return@submit
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
// 使用 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)
|
||||
// 更新任务状态为 FAILED
|
||||
val failedTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null)
|
||||
if (failedTask != null) {
|
||||
failedTask.status = "FAILED"
|
||||
failedTask.errorMessage = e.message
|
||||
failedTask.updatedAt = System.currentTimeMillis()
|
||||
backtestTaskRepository.save(failedTask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("轮询回测任务失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package com.wrbug.polymarketbot.service.backtest
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.BacktestTask
|
||||
import com.wrbug.polymarketbot.entity.BacktestTrade
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
|
||||
import com.wrbug.polymarketbot.repository.BacktestTradeRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.toJson
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 回测任务服务
|
||||
*/
|
||||
@Service
|
||||
class BacktestService(
|
||||
private val backtestTaskRepository: BacktestTaskRepository,
|
||||
private val backtestTradeRepository: BacktestTradeRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(BacktestService::class.java)
|
||||
|
||||
/**
|
||||
* 创建回测任务
|
||||
*/
|
||||
@Transactional
|
||||
fun createBacktestTask(request: BacktestCreateRequest): Result<BacktestTaskDto> {
|
||||
return try {
|
||||
// 1. 验证 Leader 是否存在
|
||||
val leader = leaderRepository.findById(request.leaderId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
|
||||
|
||||
// 2. 验证回测天数
|
||||
if (request.backtestDays < 1 || request.backtestDays > 15) {
|
||||
return Result.failure(IllegalArgumentException("回测天数必须在 1-15 之间"))
|
||||
}
|
||||
|
||||
// 3. 验证恢复页码(如果提供)
|
||||
if (request.pageForResume != null && request.pageForResume < 1) {
|
||||
return Result.failure(IllegalArgumentException("恢复页码必须大于 0"))
|
||||
}
|
||||
|
||||
// 4. 验证初始金额
|
||||
val initialBalance = request.initialBalance.toSafeBigDecimal()
|
||||
if (initialBalance <= BigDecimal.ZERO) {
|
||||
return Result.failure(IllegalArgumentException("初始金额必须大于 0"))
|
||||
}
|
||||
|
||||
// 4. 创建回测任务
|
||||
val task = BacktestTask(
|
||||
taskName = request.taskName.trim(),
|
||||
leaderId = request.leaderId,
|
||||
initialBalance = initialBalance,
|
||||
backtestDays = request.backtestDays,
|
||||
startTime = System.currentTimeMillis() - (request.backtestDays * 24 * 3600 * 1000),
|
||||
status = "PENDING",
|
||||
|
||||
// 跟单配置(不包含 max_position_count)
|
||||
copyMode = request.copyMode ?: "RATIO",
|
||||
copyRatio = request.copyRatio?.toSafeBigDecimal() ?: BigDecimal.ONE,
|
||||
fixedAmount = request.fixedAmount?.toSafeBigDecimal(),
|
||||
maxOrderSize = request.maxOrderSize?.toSafeBigDecimal() ?: "1000".toSafeBigDecimal(),
|
||||
minOrderSize = request.minOrderSize?.toSafeBigDecimal() ?: "1".toSafeBigDecimal(),
|
||||
maxDailyLoss = request.maxDailyLoss?.toSafeBigDecimal() ?: "10000".toSafeBigDecimal(),
|
||||
maxDailyOrders = request.maxDailyOrders ?: 100,
|
||||
supportSell = request.supportSell ?: true,
|
||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||
keywords = if (request.keywords != null && request.keywords.isNotEmpty()) {
|
||||
request.keywords.toJson()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal()
|
||||
)
|
||||
|
||||
backtestTaskRepository.save(task)
|
||||
|
||||
// 5. 转换为 DTO 返回
|
||||
Result.success(task.toDto(leader))
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建回测任务失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测任务列表
|
||||
*/
|
||||
fun getBacktestTaskList(request: BacktestListRequest): Result<BacktestListResponse> {
|
||||
return try {
|
||||
// 获取所有符合条件的任务
|
||||
val allTasks = when {
|
||||
request.leaderId != null && request.status != null -> {
|
||||
backtestTaskRepository.findByLeaderIdAndStatus(request.leaderId, request.status)
|
||||
}
|
||||
request.leaderId != null -> {
|
||||
backtestTaskRepository.findByLeaderId(request.leaderId)
|
||||
.filter { request.status == null || it.status == request.status }
|
||||
}
|
||||
request.status != null -> {
|
||||
backtestTaskRepository.findByStatus(request.status)
|
||||
}
|
||||
else -> {
|
||||
backtestTaskRepository.findAll()
|
||||
}
|
||||
}
|
||||
|
||||
// 排序
|
||||
val sortedTasks = when (request.sortBy) {
|
||||
"profitAmount" -> {
|
||||
if (request.sortOrder == "asc") {
|
||||
allTasks.sortedBy { it.profitAmount }
|
||||
} else {
|
||||
allTasks.sortedByDescending { it.profitAmount }
|
||||
}
|
||||
}
|
||||
"profitRate" -> {
|
||||
if (request.sortOrder == "asc") {
|
||||
allTasks.sortedBy { it.profitRate }
|
||||
} else {
|
||||
allTasks.sortedByDescending { it.profitRate }
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (request.sortOrder == "asc") {
|
||||
allTasks.sortedBy { it.createdAt }
|
||||
} else {
|
||||
allTasks.sortedByDescending { it.createdAt }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
val total = sortedTasks.size
|
||||
val pagedTasks = sortedTasks
|
||||
.drop((request.page - 1) * request.size)
|
||||
.take(request.size)
|
||||
|
||||
val list = pagedTasks.map { task ->
|
||||
val leader = leaderRepository.findById(task.leaderId).orElse(null)
|
||||
task.toDto(leader)
|
||||
}
|
||||
|
||||
Result.success(
|
||||
BacktestListResponse(
|
||||
list = list,
|
||||
total = total.toLong(),
|
||||
page = request.page,
|
||||
size = request.size
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测任务列表失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测任务详情
|
||||
*/
|
||||
fun getBacktestTaskDetail(request: BacktestDetailRequest): Result<BacktestDetailResponse> {
|
||||
return try {
|
||||
val task = backtestTaskRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
|
||||
|
||||
val leader = leaderRepository.findById(task.leaderId).orElse(null)
|
||||
|
||||
val config = BacktestConfigDto(
|
||||
copyMode = task.copyMode,
|
||||
copyRatio = task.copyRatio.toPlainString(),
|
||||
fixedAmount = task.fixedAmount?.toPlainString(),
|
||||
maxOrderSize = task.maxOrderSize.toPlainString(),
|
||||
minOrderSize = task.minOrderSize.toPlainString(),
|
||||
maxDailyLoss = task.maxDailyLoss.toPlainString(),
|
||||
maxDailyOrders = task.maxDailyOrders,
|
||||
supportSell = task.supportSell,
|
||||
keywordFilterMode = task.keywordFilterMode,
|
||||
keywords = if (task.keywords != null) {
|
||||
task.keywords.fromJson<List<String>>()
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
maxPositionValue = task.maxPositionValue?.toPlainString(),
|
||||
minPrice = task.minPrice?.toPlainString(),
|
||||
maxPrice = task.maxPrice?.toPlainString()
|
||||
)
|
||||
|
||||
val statistics = BacktestStatisticsDto(
|
||||
totalTrades = task.totalTrades,
|
||||
buyTrades = task.buyTrades,
|
||||
sellTrades = task.sellTrades,
|
||||
winTrades = task.winTrades,
|
||||
lossTrades = task.lossTrades,
|
||||
winRate = task.winRate?.toPlainString() ?: "0.00",
|
||||
maxProfit = task.maxProfit?.toPlainString() ?: "0.00",
|
||||
maxLoss = task.maxLoss?.toPlainString() ?: "0.00",
|
||||
maxDrawdown = task.maxDrawdown?.toPlainString() ?: "0.00",
|
||||
avgHoldingTime = task.avgHoldingTime
|
||||
)
|
||||
|
||||
val taskDto = task.toDto(leader)
|
||||
|
||||
Result.success(
|
||||
BacktestDetailResponse(
|
||||
task = taskDto,
|
||||
config = config,
|
||||
statistics = statistics
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测任务详情失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测交易记录
|
||||
*/
|
||||
fun getBacktestTrades(request: BacktestTradeListRequest): Result<BacktestTradeListResponse> {
|
||||
return try {
|
||||
val pageRequest = PageRequest.of(
|
||||
request.page - 1,
|
||||
request.size,
|
||||
Sort.by(Sort.Order.asc("tradeTime"))
|
||||
)
|
||||
|
||||
val tradesPage = backtestTradeRepository.findByBacktestTaskId(
|
||||
request.taskId,
|
||||
pageRequest
|
||||
)
|
||||
|
||||
val list = tradesPage.content.map { trade ->
|
||||
BacktestTradeDto(
|
||||
id = trade.id!!,
|
||||
tradeTime = trade.tradeTime,
|
||||
marketId = trade.marketId,
|
||||
marketTitle = trade.marketTitle,
|
||||
side = trade.side,
|
||||
outcome = trade.outcome,
|
||||
outcomeIndex = trade.outcomeIndex,
|
||||
quantity = trade.quantity.toPlainString(),
|
||||
price = trade.price.toPlainString(),
|
||||
amount = trade.amount.toPlainString(),
|
||||
fee = trade.fee.toPlainString(),
|
||||
profitLoss = trade.profitLoss?.toPlainString(),
|
||||
balanceAfter = trade.balanceAfter.toPlainString(),
|
||||
leaderTradeId = trade.leaderTradeId
|
||||
)
|
||||
}
|
||||
|
||||
Result.success(
|
||||
BacktestTradeListResponse(
|
||||
list = list,
|
||||
total = tradesPage.totalElements,
|
||||
page = request.page,
|
||||
size = request.size
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测交易记录失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除回测任务
|
||||
*/
|
||||
@Transactional
|
||||
fun deleteBacktestTask(request: BacktestDeleteRequest): Result<Unit> {
|
||||
return try {
|
||||
val task = backtestTaskRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
|
||||
|
||||
if (task.status == "RUNNING") {
|
||||
return Result.failure(IllegalStateException("回测任务正在运行,无法删除"))
|
||||
}
|
||||
|
||||
backtestTaskRepository.deleteById(request.id)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除回测任务失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止回测任务
|
||||
*/
|
||||
@Transactional
|
||||
fun stopBacktestTask(request: BacktestStopRequest): Result<Unit> {
|
||||
return try {
|
||||
val task = backtestTaskRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
|
||||
|
||||
if (task.status != "RUNNING") {
|
||||
return Result.failure(IllegalArgumentException("回测任务未在运行中"))
|
||||
}
|
||||
|
||||
task.status = "STOPPED"
|
||||
task.updatedAt = System.currentTimeMillis()
|
||||
backtestTaskRepository.save(task)
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("停止回测任务失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试回测任务
|
||||
* 从断点继续执行,保留已处理的交易记录
|
||||
*/
|
||||
@Transactional
|
||||
fun retryBacktestTask(request: BacktestRetryRequest): Result<Unit> {
|
||||
return try {
|
||||
val task = backtestTaskRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
|
||||
|
||||
if (task.status == "RUNNING") {
|
||||
return Result.failure(IllegalArgumentException("回测任务正在运行中,无需重试"))
|
||||
}
|
||||
|
||||
// 重置任务状态为 PENDING,进度保持不变
|
||||
task.status = "PENDING"
|
||||
task.errorMessage = null
|
||||
task.updatedAt = System.currentTimeMillis()
|
||||
|
||||
// 不清理已处理的交易记录,保留恢复点
|
||||
backtestTaskRepository.save(task)
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("重试回测任务失败", e)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展函数:BacktestTask 转 DTO
|
||||
*/
|
||||
private fun BacktestTask.toDto(leader: Leader?): BacktestTaskDto {
|
||||
return BacktestTaskDto(
|
||||
id = this.id!!,
|
||||
taskName = this.taskName,
|
||||
leaderId = this.leaderId,
|
||||
leaderName = leader?.leaderName,
|
||||
leaderAddress = leader?.leaderAddress,
|
||||
initialBalance = this.initialBalance.toPlainString(),
|
||||
finalBalance = this.finalBalance?.toPlainString(),
|
||||
profitAmount = this.profitAmount?.toPlainString(),
|
||||
profitRate = this.profitRate?.toPlainString(),
|
||||
backtestDays = this.backtestDays,
|
||||
startTime = this.startTime,
|
||||
endTime = this.endTime,
|
||||
status = this.status,
|
||||
progress = this.progress,
|
||||
totalTrades = this.totalTrades,
|
||||
createdAt = this.createdAt,
|
||||
executionStartedAt = this.executionStartedAt,
|
||||
executionFinishedAt = this.executionFinishedAt
|
||||
)
|
||||
}
|
||||
|
||||
+93
-18
@@ -8,9 +8,13 @@ import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.PositionResponse
|
||||
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
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
@@ -90,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) {
|
||||
@@ -107,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")
|
||||
@@ -255,7 +259,6 @@ class BlockchainService(
|
||||
return Result.failure(IllegalArgumentException("代理地址不能为空"))
|
||||
}
|
||||
|
||||
|
||||
// 使用 RPC 查询 USDC 余额(使用代理地址)
|
||||
val balance = queryUsdcBalanceViaRpc(proxyAddress)
|
||||
Result.success(balance)
|
||||
@@ -264,6 +267,82 @@ class BlockchainService(
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询钱包余额(通用方法)
|
||||
* 用于 Account 和 Leader 的余额查询
|
||||
* @param walletAddress 钱包地址(代理地址,Polymarket 使用代理地址存储资产)
|
||||
* @return WalletBalanceResponse 包含可用余额、仓位余额、总余额和持仓列表
|
||||
*/
|
||||
suspend fun getWalletBalance(walletAddress: String): Result<WalletBalanceResponse> {
|
||||
return try {
|
||||
if (walletAddress.isBlank()) {
|
||||
logger.error("钱包地址为空,无法查询余额")
|
||||
return Result.failure(IllegalArgumentException("钱包地址不能为空"))
|
||||
}
|
||||
|
||||
// 1. 查询持仓信息(用于返回持仓列表)
|
||||
val positionsResult = getPositions(walletAddress)
|
||||
val positions = if (positionsResult.isSuccess) {
|
||||
// 过滤掉价值为0的仓位
|
||||
positionsResult.getOrNull()?.filter { pos ->
|
||||
val currentValue = pos.currentValue ?: 0.0
|
||||
currentValue > 0
|
||||
}?.map { pos ->
|
||||
PositionDto(
|
||||
marketId = pos.conditionId ?: "",
|
||||
title = pos.title,
|
||||
side = pos.outcome ?: "",
|
||||
quantity = pos.size?.toString() ?: "0",
|
||||
avgPrice = pos.avgPrice?.toString() ?: "0",
|
||||
currentValue = pos.currentValue?.toString() ?: "0",
|
||||
pnl = pos.cashPnl?.toString()
|
||||
)
|
||||
} ?: emptyList()
|
||||
} else {
|
||||
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// 2. 使用 /value 接口获取仓位总价值
|
||||
val positionBalanceResult = getTotalValue(walletAddress)
|
||||
val positionBalance = if (positionBalanceResult.isSuccess) {
|
||||
positionBalanceResult.getOrNull() ?: "0"
|
||||
} else {
|
||||
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
|
||||
"0"
|
||||
}
|
||||
|
||||
// 3. 查询可用余额(通过 RPC 查询 USDC 余额)
|
||||
val availableBalanceResult = getUsdcBalance(
|
||||
walletAddress = walletAddress,
|
||||
proxyAddress = walletAddress
|
||||
)
|
||||
val availableBalance = if (availableBalanceResult.isSuccess) {
|
||||
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
|
||||
} else {
|
||||
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
|
||||
val error = availableBalanceResult.exceptionOrNull()
|
||||
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
|
||||
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
|
||||
}
|
||||
|
||||
// 4. 计算总余额 = 可用余额 + 仓位余额
|
||||
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
|
||||
|
||||
Result.success(
|
||||
WalletBalanceResponse(
|
||||
availableBalance = availableBalance,
|
||||
positionBalance = positionBalance,
|
||||
totalBalance = totalBalance.toPlainString(),
|
||||
positions = positions
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询钱包余额失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 RPC 查询 USDC 余额
|
||||
@@ -502,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)
|
||||
|
||||
@@ -89,8 +89,6 @@ class MarketPollingService(
|
||||
*/
|
||||
private suspend fun checkAndUpdateMissingMarkets() {
|
||||
try {
|
||||
logger.debug("开始检查缺失的市场信息...")
|
||||
|
||||
// 1. 获取所有买入订单的市场ID(去重)
|
||||
val allOrders = copyOrderTrackingRepository.findAll()
|
||||
val marketIds = allOrders.map { it.marketId }.distinct()
|
||||
@@ -99,9 +97,6 @@ class MarketPollingService(
|
||||
logger.debug("没有找到任何订单,跳过市场信息检查")
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("找到 ${marketIds.size} 个不同的市场ID")
|
||||
|
||||
// 2. 检查哪些市场信息在数据库中缺失
|
||||
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
|
||||
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
|
||||
@@ -113,7 +108,6 @@ class MarketPollingService(
|
||||
}
|
||||
|
||||
if (validMissingMarketIds.isEmpty()) {
|
||||
logger.debug("所有市场信息都已存在,无需更新")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+61
-3
@@ -9,6 +9,8 @@ import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import com.github.benmanes.caffeine.cache.Cache
|
||||
import com.github.benmanes.caffeine.cache.Caffeine
|
||||
|
||||
/**
|
||||
* 市场价格服务
|
||||
@@ -27,6 +29,21 @@ class MarketPriceService(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(MarketPriceService::class.java)
|
||||
|
||||
/**
|
||||
* 已结算市场的价格缓存
|
||||
* Key: "marketId:outcomeIndex"
|
||||
* Value: BigDecimal (1.0 或 0.0)
|
||||
*
|
||||
* 缓存策略:
|
||||
* - 最大缓存 10,000 个已结算市场
|
||||
* - 永不过期(已结算的市场状态永不改变)
|
||||
* - 内存占用约: 10,000 * ~100 bytes = ~1MB
|
||||
*/
|
||||
private val settledMarketCache: Cache<String, BigDecimal> = Caffeine.newBuilder()
|
||||
.maximumSize(10_000)
|
||||
.recordStats() // 启用统计信息
|
||||
.build()
|
||||
|
||||
/**
|
||||
* 获取当前市场最新价
|
||||
* 优先级:
|
||||
@@ -83,9 +100,20 @@ class MarketPriceService(
|
||||
* - payout == 0(输了)→ 返回 0.0
|
||||
* 如果市场未结算或查询失败,返回 null
|
||||
*
|
||||
* 使用缓存优化:已结算的市场结果会被缓存,避免重复 RPC 调用
|
||||
*
|
||||
* @return Pair<BigDecimal?, Boolean> 第一个值是价格(如果已结算),第二个值表示是否发生了 RPC 错误(execution reverted)
|
||||
*/
|
||||
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair<BigDecimal?, Boolean> {
|
||||
// 1. 先检查缓存
|
||||
val cacheKey = "$marketId:$outcomeIndex"
|
||||
val cachedPrice = settledMarketCache.getIfPresent(cacheKey)
|
||||
if (cachedPrice != null) {
|
||||
logger.debug("从缓存获取已结算市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex, price=$cachedPrice")
|
||||
return Pair(cachedPrice, false)
|
||||
}
|
||||
|
||||
// 2. 缓存未命中,发起 RPC 查询
|
||||
return try {
|
||||
val chainResult = blockchainService.getCondition(marketId)
|
||||
chainResult.fold(
|
||||
@@ -96,11 +124,17 @@ class MarketPriceService(
|
||||
when {
|
||||
payout > BigInteger.ZERO -> {
|
||||
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
return Pair(BigDecimal.ONE, false)
|
||||
val price = BigDecimal.ONE
|
||||
// 缓存已结算的结果
|
||||
settledMarketCache.put(cacheKey, price)
|
||||
return Pair(price, false)
|
||||
}
|
||||
payout == BigInteger.ZERO -> {
|
||||
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
return Pair(BigDecimal.ZERO, false)
|
||||
val price = BigDecimal.ZERO
|
||||
// 缓存已结算的结果
|
||||
settledMarketCache.put(cacheKey, price)
|
||||
return Pair(price, false)
|
||||
}
|
||||
else -> {
|
||||
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
@@ -109,7 +143,7 @@ class MarketPriceService(
|
||||
}
|
||||
} else {
|
||||
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
|
||||
Pair(null, false)
|
||||
Pair(null, false) // 未结算的市场不缓存
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -290,5 +324,29 @@ class MarketPriceService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
* 用于监控缓存命中率和性能
|
||||
*/
|
||||
fun getCacheStats(): String {
|
||||
val stats = settledMarketCache.stats()
|
||||
return """
|
||||
已结算市场缓存统计:
|
||||
- 缓存条目数: ${settledMarketCache.estimatedSize()}
|
||||
- 命中次数: ${stats.hitCount()}
|
||||
- 未命中次数: ${stats.missCount()}
|
||||
- 命中率: ${"%.2f".format(stats.hitRate() * 100)}%
|
||||
- 总请求次数: ${stats.requestCount()}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空缓存(测试或管理用)
|
||||
*/
|
||||
fun clearSettledMarketCache() {
|
||||
settledMarketCache.invalidateAll()
|
||||
logger.info("已清空已结算市场缓存")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
+58
-56
@@ -7,6 +7,7 @@ import com.wrbug.polymarketbot.util.lt
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.util.DateUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
@@ -45,7 +46,8 @@ class CopyTradingFilterService(
|
||||
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
|
||||
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
|
||||
marketTitle: String? = null, // 市场标题,用于关键字过滤
|
||||
marketEndDate: Long? = null // 市场截止时间,用于市场截止时间检查
|
||||
marketEndDate: Long? = null, // 市场截止时间,用于市场截止时间检查
|
||||
outcomeIndex: Int? = null // 方向索引(0, 1, 2, ...),用于按市场+方向检查仓位
|
||||
): FilterResult {
|
||||
// 1. 关键字过滤检查(如果配置了关键字过滤)
|
||||
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
|
||||
@@ -79,7 +81,7 @@ class CopyTradingFilterService(
|
||||
if (!needOrderbook) {
|
||||
// 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||
if (copyOrderAmount != null && marketId != null) {
|
||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
|
||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
|
||||
if (!positionCheck.isPassed) {
|
||||
return positionCheck
|
||||
}
|
||||
@@ -116,7 +118,7 @@ class CopyTradingFilterService(
|
||||
|
||||
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||
if (copyOrderAmount != null && marketId != null) {
|
||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
|
||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
|
||||
if (!positionCheck.isPassed) {
|
||||
return positionCheck
|
||||
}
|
||||
@@ -201,12 +203,16 @@ class CopyTradingFilterService(
|
||||
|
||||
// 检查最低价格
|
||||
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) {
|
||||
return FilterResult.priceRangeFailed("价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
|
||||
val priceStr = tradePrice.stripTrailingZeros().toPlainString()
|
||||
val minPriceStr = copyTrading.minPrice.stripTrailingZeros().toPlainString()
|
||||
return FilterResult.priceRangeFailed("价格低于最低限制: $priceStr < $minPriceStr")
|
||||
}
|
||||
|
||||
// 检查最高价格
|
||||
if (copyTrading.maxPrice != null && tradePrice.gt(copyTrading.maxPrice)) {
|
||||
return FilterResult.priceRangeFailed("价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
|
||||
val priceStr = tradePrice.stripTrailingZeros().toPlainString()
|
||||
val maxPriceStr = copyTrading.maxPrice.stripTrailingZeros().toPlainString()
|
||||
return FilterResult.priceRangeFailed("价格高于最高限制: $priceStr > $maxPriceStr")
|
||||
}
|
||||
|
||||
return FilterResult.passed()
|
||||
@@ -244,7 +250,9 @@ class CopyTradingFilterService(
|
||||
val spread = bestAsk.subtract(bestBid)
|
||||
|
||||
if (spread.gt(copyTrading.maxSpread)) {
|
||||
return FilterResult.spreadFailed("价差过大: $spread > ${copyTrading.maxSpread}", orderbook)
|
||||
val spreadStr = spread.stripTrailingZeros().toPlainString()
|
||||
val maxSpreadStr = copyTrading.maxSpread.stripTrailingZeros().toPlainString()
|
||||
return FilterResult.spreadFailed("价差过大: $spreadStr > $maxSpreadStr", orderbook)
|
||||
}
|
||||
|
||||
return FilterResult.passed()
|
||||
@@ -284,94 +292,86 @@ class CopyTradingFilterService(
|
||||
val totalDepth = bidsDepth.add(asksDepth)
|
||||
|
||||
if (totalDepth.lt(copyTrading.minOrderDepth)) {
|
||||
return FilterResult.orderDepthFailed("订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}", orderbook)
|
||||
val totalDepthStr = totalDepth.stripTrailingZeros().toPlainString()
|
||||
val minDepthStr = copyTrading.minOrderDepth.stripTrailingZeros().toPlainString()
|
||||
return FilterResult.orderDepthFailed("订单深度不足: $totalDepthStr < $minDepthStr", orderbook)
|
||||
}
|
||||
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查仓位限制(按市场检查)
|
||||
* 检查仓位限制(按市场+方向检查)
|
||||
* @param copyTrading 跟单配置
|
||||
* @param copyOrderAmount 跟单金额(USDC)
|
||||
* @param marketId 市场ID,用于过滤该市场的仓位
|
||||
* @param outcomeIndex 方向索引(0, 1, 2, ...),用于按市场+方向检查仓位
|
||||
* @return 过滤结果
|
||||
*/
|
||||
private suspend fun checkPositionLimits(
|
||||
copyTrading: CopyTrading,
|
||||
copyOrderAmount: BigDecimal,
|
||||
marketId: String
|
||||
marketId: String,
|
||||
outcomeIndex: Int?
|
||||
): FilterResult {
|
||||
// 如果未配置仓位限制,直接通过
|
||||
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
|
||||
if (copyTrading.maxPositionValue == null) {
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// 获取账户的所有仓位信息
|
||||
val positionsResult = accountService.getAllPositions()
|
||||
if (positionsResult.isFailure) {
|
||||
logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, error=${positionsResult.exceptionOrNull()?.message}")
|
||||
logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${positionsResult.exceptionOrNull()?.message}")
|
||||
// 如果获取仓位失败,为了安全起见,不通过检查
|
||||
return FilterResult.maxPositionValueFailed("获取仓位信息失败,无法进行仓位检查")
|
||||
}
|
||||
|
||||
|
||||
val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空")
|
||||
|
||||
|
||||
// 过滤出当前账户且该市场的仓位
|
||||
val marketPositions = positions.currentPositions.filter {
|
||||
val marketPositions = positions.currentPositions.filter {
|
||||
it.accountId == copyTrading.accountId && it.marketId == marketId
|
||||
}
|
||||
|
||||
|
||||
// 检查最大仓位金额(如果配置了)
|
||||
if (copyTrading.maxPositionValue != null) {
|
||||
// 比较数据库成本价(本地订单记录)和外部持仓市值(可能来自其他终端的操作),取最大值
|
||||
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarket(copyTrading.id!!, marketId) ?: BigDecimal.ZERO
|
||||
val extValue = marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
|
||||
if (copyTrading.maxPositionValue != null && outcomeIndex != null) {
|
||||
// 按市场+方向(outcomeIndex)分别计算数据库成本价
|
||||
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarketAndOutcomeIndex(
|
||||
copyTrading.id!!, marketId, outcomeIndex
|
||||
) ?: BigDecimal.ZERO
|
||||
|
||||
// 外部持仓也需要按方向过滤,但由于外部持仓可能没有 outcomeIndex 信息,这里保守处理:
|
||||
// 如果外部持仓存在,取该市场的所有外部持仓市值(与数据库取最大值)
|
||||
val extValue = if (marketPositions.isNotEmpty()) {
|
||||
marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
|
||||
// 取数据库值和外部持仓值的最大值
|
||||
val currentPositionValue = dbValue.max(extValue)
|
||||
|
||||
// 检查:该市场的当前仓位 + 跟单金额 <= 最大仓位金额
|
||||
|
||||
// 检查:该市场该方向的当前仓位 + 跟单金额 <= 最大仓位金额
|
||||
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
|
||||
|
||||
|
||||
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
|
||||
val currentValueStr = currentPositionValue.stripTrailingZeros().toPlainString()
|
||||
val dbValueStr = dbValue.stripTrailingZeros().toPlainString()
|
||||
val extValueStr = extValue.stripTrailingZeros().toPlainString()
|
||||
val orderAmountStr = copyOrderAmount.stripTrailingZeros().toPlainString()
|
||||
val totalValueStr = totalValueAfterOrder.stripTrailingZeros().toPlainString()
|
||||
val maxValueStr = copyTrading.maxPositionValue.stripTrailingZeros().toPlainString()
|
||||
return FilterResult.maxPositionValueFailed(
|
||||
"超过最大仓位金额限制: 当前该市场仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
|
||||
"超过最大仓位金额限制: 市场=$marketId, 方向=$outcomeIndex, 当前仓位(取最大值)=${currentValueStr} USDC (DB=${dbValueStr}, Ext=${extValueStr}), 跟单金额=${orderAmountStr} USDC, 总计=${totalValueStr} USDC > 最大限制=${maxValueStr} USDC"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查最大仓位数量(如果配置了)
|
||||
if (copyTrading.maxPositionCount != null) {
|
||||
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题)
|
||||
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
|
||||
|
||||
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
|
||||
val extCount = positions.currentPositions
|
||||
.filter { it.accountId == copyTrading.accountId }
|
||||
.map { it.marketId }
|
||||
.distinct()
|
||||
.size
|
||||
|
||||
val currentPositionCount = maxOf(dbCount, extCount)
|
||||
|
||||
// 检查:如果当前没有该市场的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
|
||||
// 判断当前市场是否已有活跃仓位(数据库或外部持仓)
|
||||
val hasDbPosition = copyOrderTrackingRepository.existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(
|
||||
copyTrading.id, marketId, BigDecimal.ZERO
|
||||
)
|
||||
val hasExtPosition = marketPositions.isNotEmpty()
|
||||
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
|
||||
|
||||
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
|
||||
return FilterResult.maxPositionCountFailed(
|
||||
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return FilterResult.passed()
|
||||
} catch (e: Exception) {
|
||||
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, error=${e.message}", e)
|
||||
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e)
|
||||
// 如果检查异常,为了安全起见,不通过检查
|
||||
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
|
||||
}
|
||||
@@ -402,8 +402,10 @@ class CopyTradingFilterService(
|
||||
val remainingTime = marketEndDate - currentTime
|
||||
|
||||
if (remainingTime > copyTrading.maxMarketEndDate) {
|
||||
val remainingTimeFormatted = DateUtils.formatDuration(remainingTime)
|
||||
val maxLimitFormatted = DateUtils.formatDuration(copyTrading.maxMarketEndDate)
|
||||
return FilterResult.marketEndDateFailed(
|
||||
"市场截止时间超出限制: 剩余时间=${remainingTime}ms (${remainingTime / (1000 * 60 * 60)}小时) > 最大限制=${copyTrading.maxMarketEndDate}ms (${copyTrading.maxMarketEndDate / (1000 * 60 * 60)}小时)"
|
||||
"市场截止时间超出限制: 剩余时间=${remainingTimeFormatted} > 最大限制=${maxLimitFormatted}"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
-15
@@ -100,7 +100,6 @@ class CopyTradingService(
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
maxPositionCount = request.maxPositionCount,
|
||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||
keywords = convertKeywordsToJson(request.keywords),
|
||||
maxMarketEndDate = request.maxMarketEndDate,
|
||||
@@ -132,7 +131,6 @@ class CopyTradingService(
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal(),
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
maxPositionCount = request.maxPositionCount,
|
||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||
keywords = convertKeywordsToJson(request.keywords),
|
||||
maxMarketEndDate = request.maxMarketEndDate,
|
||||
@@ -164,7 +162,6 @@ class CopyTradingService(
|
||||
minPrice = config.minPrice,
|
||||
maxPrice = config.maxPrice,
|
||||
maxPositionValue = config.maxPositionValue,
|
||||
maxPositionCount = config.maxPositionCount,
|
||||
keywordFilterMode = config.keywordFilterMode,
|
||||
keywords = config.keywords,
|
||||
configName = configName,
|
||||
@@ -282,16 +279,6 @@ class CopyTradingService(
|
||||
} else {
|
||||
copyTrading.maxPositionValue
|
||||
},
|
||||
// 处理 maxPositionCount:-1 表示要清空(设置为 null),null 表示不更新
|
||||
maxPositionCount = if (request.maxPositionCount != null) {
|
||||
if (request.maxPositionCount == -1) {
|
||||
null
|
||||
} else {
|
||||
request.maxPositionCount
|
||||
}
|
||||
} else {
|
||||
copyTrading.maxPositionCount
|
||||
},
|
||||
keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode,
|
||||
keywords = if (request.keywords != null) {
|
||||
convertKeywordsToJson(request.keywords)
|
||||
@@ -520,7 +507,6 @@ class CopyTradingService(
|
||||
minPrice = copyTrading.minPrice?.toPlainString(),
|
||||
maxPrice = copyTrading.maxPrice?.toPlainString(),
|
||||
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
|
||||
maxPositionCount = copyTrading.maxPositionCount,
|
||||
keywordFilterMode = copyTrading.keywordFilterMode,
|
||||
keywords = convertJsonToKeywords(copyTrading.keywords),
|
||||
configName = copyTrading.configName,
|
||||
@@ -585,7 +571,6 @@ class CopyTradingService(
|
||||
val minPrice: BigDecimal?,
|
||||
val maxPrice: BigDecimal?,
|
||||
val maxPositionValue: BigDecimal?,
|
||||
val maxPositionCount: Int?,
|
||||
val keywordFilterMode: String,
|
||||
val keywords: String?, // JSON 字符串
|
||||
val maxMarketEndDate: Long?, // 市场截止时间限制(毫秒时间戳)
|
||||
|
||||
-8
@@ -20,8 +20,6 @@ enum class FilterStatus {
|
||||
FAILED_ORDER_DEPTH,
|
||||
/** 失败:超过最大仓位金额 */
|
||||
FAILED_MAX_POSITION_VALUE,
|
||||
/** 失败:超过最大仓位数量 */
|
||||
FAILED_MAX_POSITION_COUNT,
|
||||
/** 失败:关键字过滤 */
|
||||
FAILED_KEYWORD_FILTER,
|
||||
/** 失败:市场截止时间超出限制 */
|
||||
@@ -87,12 +85,6 @@ data class FilterResult(
|
||||
status = FilterStatus.FAILED_MAX_POSITION_VALUE,
|
||||
reason = reason
|
||||
)
|
||||
|
||||
/** 超过最大仓位数量 */
|
||||
fun maxPositionCountFailed(reason: String) = FilterResult(
|
||||
status = FilterStatus.FAILED_MAX_POSITION_COUNT,
|
||||
reason = reason
|
||||
)
|
||||
|
||||
/** 关键字过滤失败 */
|
||||
fun keywordFilterFailed(reason: String) = FilterResult(
|
||||
|
||||
+48
-6
@@ -3,12 +3,15 @@ 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
|
||||
import com.wrbug.polymarketbot.util.CategoryValidator
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* Leader 管理服务
|
||||
@@ -17,9 +20,11 @@ import org.springframework.transaction.annotation.Transactional
|
||||
class LeaderService(
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val copyTradingRepository: CopyTradingRepository
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val backtestTaskRepository: BacktestTaskRepository,
|
||||
private val blockchainService: BlockchainService
|
||||
) {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(LeaderService::class.java)
|
||||
|
||||
/**
|
||||
@@ -154,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(
|
||||
@@ -176,19 +182,54 @@ class LeaderService(
|
||||
return try {
|
||||
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||
?: 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Leader 余额
|
||||
* 使用代理地址查询 USDC 余额和持仓信息
|
||||
*/
|
||||
fun getLeaderBalance(leaderId: Long): Result<LeaderBalanceResponse> {
|
||||
return try {
|
||||
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
|
||||
|
||||
// Leader 的 leaderAddress 就是代理地址
|
||||
val walletAddress = leader.leaderAddress
|
||||
|
||||
// 使用通用方法查询余额
|
||||
val balanceResult = runBlocking {
|
||||
blockchainService.getWalletBalance(walletAddress)
|
||||
}
|
||||
|
||||
balanceResult.map { walletBalance: WalletBalanceResponse ->
|
||||
LeaderBalanceResponse(
|
||||
leaderId = leader.id!!,
|
||||
leaderAddress = leader.leaderAddress,
|
||||
leaderName = leader.leaderName,
|
||||
availableBalance = walletBalance.availableBalance,
|
||||
positionBalance = walletBalance.positionBalance,
|
||||
totalBalance = walletBalance.totalBalance,
|
||||
positions = walletBalance.positions
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询 Leader 余额失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 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,
|
||||
@@ -197,6 +238,7 @@ class LeaderService(
|
||||
remark = leader.remark,
|
||||
website = leader.website,
|
||||
copyTradingCount = copyTradingCount,
|
||||
backtestCount = backtestCount,
|
||||
createdAt = leader.createdAt,
|
||||
updatedAt = leader.updatedAt
|
||||
)
|
||||
|
||||
+107
-45
@@ -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
|
||||
@@ -36,13 +46,11 @@ class OrderSigningService {
|
||||
size = 2,
|
||||
amount = 4
|
||||
)
|
||||
|
||||
// 金额精度限制(根据 Polymarket API 要求)
|
||||
// makerAmount (USDC) 最多 2 位小数
|
||||
// takerAmount (shares) 最多 4 位小数
|
||||
private val MAKER_AMOUNT_DECIMALS = 2 // USDC 金额精度
|
||||
private val TAKER_AMOUNT_DECIMALS = 4 // shares 数量精度
|
||||
|
||||
|
||||
// 价格有效范围(Polymarket API 要求)
|
||||
private val MIN_PRICE = BigDecimal("0.01")
|
||||
private val MAX_PRICE = BigDecimal("0.99")
|
||||
|
||||
/**
|
||||
* 订单金额计算结果
|
||||
*/
|
||||
@@ -62,7 +70,9 @@ class OrderSigningService {
|
||||
|
||||
/**
|
||||
* 计算订单金额(makerAmount 和 takerAmount)
|
||||
*
|
||||
*
|
||||
* 参考 clob-client/src/order-builder/helpers.ts 的 getOrderRawAmounts 函数
|
||||
*
|
||||
* @param side BUY 或 SELL
|
||||
* @param size 数量(shares)
|
||||
* @param price 价格(0-1 之间)
|
||||
@@ -77,49 +87,59 @@ class OrderSigningService {
|
||||
): OrderAmounts {
|
||||
val sizeDecimal = size.toSafeBigDecimal()
|
||||
val priceDecimal = price.toSafeBigDecimal()
|
||||
|
||||
// 对价格进行 roundNormal 处理(与 clob-client 保持一致)
|
||||
var rawPrice = roundNormal(priceDecimal, roundConfig.price)
|
||||
|
||||
// 验证价格范围,如果超出则调整到最接近的有效值
|
||||
// Polymarket API 要求: 0.01 <= price <= 0.99
|
||||
if (rawPrice > MAX_PRICE) {
|
||||
logger.warn("价格超出最大限制,已调整: $priceDecimal -> $MAX_PRICE")
|
||||
rawPrice = MAX_PRICE
|
||||
} else if (rawPrice < MIN_PRICE) {
|
||||
logger.warn("价格低于最小限制,已调整: $priceDecimal -> $MIN_PRICE")
|
||||
rawPrice = MIN_PRICE
|
||||
}
|
||||
|
||||
if (side.uppercase() == "BUY") {
|
||||
// BUY: makerAmount = price * size (USDC), takerAmount = size (shares)
|
||||
// makerAmount 是 USDC 金额,最多 2 位小数
|
||||
// takerAmount 是 shares 数量,最多 4 位小数
|
||||
val rawTakerAmt = roundDown(sizeDecimal, roundConfig.size)
|
||||
|
||||
// makerAmount = price * size,使用原始价格计算(与SDK保持一致)
|
||||
// 先使用原始价格计算,然后再进行舍入,确保精度一致
|
||||
var rawMakerAmt = rawTakerAmt.multiply(priceDecimal)
|
||||
|
||||
// 确保 makerAmount 精度(USDC,最多 2 位小数)
|
||||
rawMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS)
|
||||
|
||||
// 确保 takerAmount 精度(shares,最多 4 位小数)
|
||||
val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS)
|
||||
|
||||
// 参考 clob-client/src/order-builder/helpers.ts 第 73-89 行
|
||||
// 注意:Polymarket API 要求市场买入订单的 makerAmount 最多 2 位小数,takerAmount 最多 4 位小数
|
||||
// takerAmount (shares) 使用 4 位小数
|
||||
val rawTakerAmt = roundDown(sizeDecimal, 4)
|
||||
|
||||
var rawMakerAmt = rawTakerAmt.multiply(rawPrice)
|
||||
// makerAmount (USDC) 使用 2 位小数
|
||||
if (decimalPlaces(rawMakerAmt) > 2) {
|
||||
rawMakerAmt = roundUp(rawMakerAmt, 2 + 4)
|
||||
if (decimalPlaces(rawMakerAmt) > 2) {
|
||||
rawMakerAmt = roundDown(rawMakerAmt, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为 wei(6 位小数)
|
||||
val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
val takerAmount = parseUnits(finalTakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
|
||||
val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
|
||||
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
|
||||
} else {
|
||||
// SELL: makerAmount = size (shares), takerAmount = price * size (USDC)
|
||||
// 根据 Polymarket API 要求:
|
||||
// - makerAmount (shares) 最多 2 位小数
|
||||
// - takerAmount (USDC) 最多 4 位小数
|
||||
// 参考 clob-client/src/order-builder/helpers.ts 第 90-105 行
|
||||
val rawMakerAmt = roundDown(sizeDecimal, roundConfig.size)
|
||||
|
||||
// takerAmount = price * size,使用原始价格计算(不使用舍入后的价格)
|
||||
// SDK期望使用原始价格进行计算,以保留足够的精度
|
||||
// 例如:0.9596 * 16.09 = 15.439964,而不是 0.96 * 16.09 = 15.4464
|
||||
val rawTakerAmt = rawMakerAmt.multiply(priceDecimal)
|
||||
|
||||
// 确保 makerAmount 精度(shares,最多 2 位小数,符合 API 要求)
|
||||
val finalMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS)
|
||||
|
||||
// 确保 takerAmount 精度(USDC,最多 4 位小数,符合 API 要求)
|
||||
val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS)
|
||||
|
||||
|
||||
var rawTakerAmt = rawMakerAmt.multiply(rawPrice)
|
||||
// 如果 takerAmount 的小数位数超过 roundConfig.amount,进行特殊舍入处理
|
||||
if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
|
||||
rawTakerAmt = roundUp(rawTakerAmt, roundConfig.amount + 4)
|
||||
if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
|
||||
rawTakerAmt = roundDown(rawTakerAmt, roundConfig.amount)
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为 wei(6 位小数)
|
||||
val makerAmount = parseUnits(finalMakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
val takerAmount = parseUnits(finalTakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
|
||||
val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||
|
||||
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
|
||||
}
|
||||
}
|
||||
@@ -324,23 +344,65 @@ class OrderSigningService {
|
||||
|
||||
/**
|
||||
* 正常舍入(四舍五入)
|
||||
* 参考 clob-client/src/utilities.ts 的 roundNormal 函数
|
||||
* 只有当小数位数超过 decimals 时才进行舍入
|
||||
*
|
||||
* @param value 要舍入的数值
|
||||
* @param decimals 目标小数位数
|
||||
* @return 舍入后的数值
|
||||
*/
|
||||
private fun roundNormal(value: BigDecimal, decimals: Int): BigDecimal {
|
||||
if (decimalPlaces(value) <= decimals) {
|
||||
return value
|
||||
}
|
||||
return value.setScale(decimals, RoundingMode.HALF_UP)
|
||||
}
|
||||
|
||||
/**
|
||||
* 向下舍入
|
||||
* 参考 clob-client/src/utilities.ts 的 roundDown 函数
|
||||
* 只有当小数位数超过 decimals 时才进行舍入
|
||||
*
|
||||
* @param value 要舍入的数值
|
||||
* @param decimals 目标小数位数
|
||||
* @return 舍入后的数值
|
||||
*/
|
||||
private fun roundDown(value: BigDecimal, decimals: Int): BigDecimal {
|
||||
if (decimalPlaces(value) <= decimals) {
|
||||
return value
|
||||
}
|
||||
return value.setScale(decimals, RoundingMode.DOWN)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 向上舍入
|
||||
* 参考 clob-client/src/utilities.ts 的 roundUp 函数
|
||||
* 只有当小数位数超过 decimals 时才进行舍入
|
||||
*
|
||||
* @param value 要舍入的数值
|
||||
* @param decimals 目标小数位数
|
||||
* @return 舍入后的数值
|
||||
*/
|
||||
private fun roundUp(value: BigDecimal, decimals: Int): BigDecimal {
|
||||
if (decimalPlaces(value) <= decimals) {
|
||||
return value
|
||||
}
|
||||
return value.setScale(decimals, RoundingMode.UP)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 BigDecimal 的小数位数
|
||||
* 参考 clob-client/src/utilities.ts 的 decimalPlaces 函数
|
||||
*
|
||||
* @param value 要计算的数值
|
||||
* @return 小数位数
|
||||
*/
|
||||
private fun decimalPlaces(value: BigDecimal): Int {
|
||||
if (value.scale() <= 0) {
|
||||
return 0
|
||||
}
|
||||
// 去除尾部的零,获取真实的小数位数
|
||||
return value.stripTrailingZeros().scale()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-16
@@ -185,6 +185,7 @@ open class CopyOrderTrackingService(
|
||||
processedAt = System.currentTimeMillis()
|
||||
)
|
||||
processedTradeRepository.save(processed)
|
||||
|
||||
} catch (e: Exception) {
|
||||
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
|
||||
if (isUniqueConstraintViolation(e)) {
|
||||
@@ -302,7 +303,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
// 过滤条件检查(在计算订单参数之前)
|
||||
// 传入 Leader 交易价格,用于价格区间检查
|
||||
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
|
||||
// 传入跟单金额和市场ID,用于仓位检查(按市场+方向检查仓位)
|
||||
// 传入市场标题,用于关键字过滤
|
||||
// 传入市场截止时间,用于市场截止时间检查
|
||||
// 订单簿只请求一次,返回给后续逻辑使用
|
||||
@@ -313,7 +314,8 @@ open class CopyOrderTrackingService(
|
||||
copyOrderAmount = copyOrderAmount,
|
||||
marketId = trade.market,
|
||||
marketTitle = marketTitle,
|
||||
marketEndDate = marketEndDate
|
||||
marketEndDate = marketEndDate,
|
||||
outcomeIndex = trade.outcomeIndex
|
||||
)
|
||||
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
||||
if (!filterResult.isPassed) {
|
||||
@@ -568,7 +570,8 @@ open class CopyOrderTrackingService(
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id!!,
|
||||
tradeId = trade.id,
|
||||
feeRateBps = feeRateBps
|
||||
feeRateBps = feeRateBps,
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
)
|
||||
|
||||
// 处理订单创建失败
|
||||
@@ -703,8 +706,8 @@ open class CopyOrderTrackingService(
|
||||
private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal {
|
||||
return when (copyTrading.copyMode) {
|
||||
"RATIO" -> {
|
||||
// 比例模式:Leader 数量 × (比例 / 100)
|
||||
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
||||
// 比例模式:Leader 数量 × 比例倍数(copyRatio 已经是倍数值,如 1.3 表示 130%)
|
||||
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
}
|
||||
|
||||
"FIXED" -> {
|
||||
@@ -736,7 +739,7 @@ open class CopyOrderTrackingService(
|
||||
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
|
||||
?: run {
|
||||
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||
}
|
||||
|
||||
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
|
||||
@@ -807,7 +810,7 @@ open class CopyOrderTrackingService(
|
||||
// 如果无法计算总比例(查询失败),使用默认比例
|
||||
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
|
||||
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||
}
|
||||
|
||||
// 计算实际比例:跟单买入数量 / Leader 买入数量
|
||||
@@ -883,13 +886,13 @@ open class CopyOrderTrackingService(
|
||||
}
|
||||
|
||||
"RATIO" -> {
|
||||
// 比例模式:直接使用配置的 copyRatio (需要除以100)
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
||||
// 比例模式:直接使用配置的 copyRatio(已经是倍数值,如 1.3 表示 130%)
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -992,7 +995,7 @@ open class CopyOrderTrackingService(
|
||||
"0"
|
||||
}
|
||||
|
||||
// 9. 创建并签名卖出订单
|
||||
// 9. 创建并签名卖出订单(按账户钱包类型使用对应 signatureType)
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedPrivateKey,
|
||||
@@ -1001,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"
|
||||
@@ -1042,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) {
|
||||
@@ -1135,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(
|
||||
@@ -1148,7 +1153,8 @@ open class CopyOrderTrackingService(
|
||||
owner: String,
|
||||
copyTradingId: Long,
|
||||
tradeId: String,
|
||||
feeRateBps: String
|
||||
feeRateBps: String,
|
||||
signatureType: Int
|
||||
): Result<String> {
|
||||
var lastError: Exception? = null
|
||||
|
||||
@@ -1163,7 +1169,7 @@ open class CopyOrderTrackingService(
|
||||
side = side,
|
||||
price = price,
|
||||
size = size,
|
||||
signatureType = 2, // Browser Wallet
|
||||
signatureType = signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0"
|
||||
@@ -1429,7 +1435,6 @@ open class CopyOrderTrackingService(
|
||||
FilterStatus.FAILED_SPREAD -> "SPREAD"
|
||||
FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH"
|
||||
FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE"
|
||||
FilterStatus.FAILED_MAX_POSITION_COUNT -> "MAX_POSITION_COUNT"
|
||||
FilterStatus.FAILED_KEYWORD_FILTER -> "KEYWORD_FILTER"
|
||||
FilterStatus.FAILED_MARKET_END_DATE -> "MARKET_END_DATE"
|
||||
}
|
||||
|
||||
+9
-1
@@ -799,7 +799,15 @@ class CopyTradingStatisticsService(
|
||||
stats = stats,
|
||||
orders = orderDtos as List<Any>
|
||||
)
|
||||
}.sortedByDescending { it.stats.count }
|
||||
}.sortedByDescending { group ->
|
||||
// 找出该市场最近的卖出订单时间(与买入订单分组排序规则一致)
|
||||
group.orders.mapNotNull { order ->
|
||||
when (order) {
|
||||
is SellOrderInfo -> order.createdAt
|
||||
else -> null
|
||||
}
|
||||
}.maxOrNull() ?: 0L
|
||||
}
|
||||
|
||||
// 5. 分页
|
||||
val page = (request.page ?: 1)
|
||||
|
||||
+104
-62
@@ -69,9 +69,6 @@ class OrderStatusUpdateService(
|
||||
// 订单详情为 null 的重试时间窗口(1分钟)
|
||||
private val ORDER_NULL_RETRY_WINDOW_MS = 60000L
|
||||
|
||||
// 订单详情为 null 但已部分卖出的清理时间窗口(1小时)
|
||||
private val PARTIAL_SOLD_CLEANUP_WINDOW_MS = 3600000L
|
||||
|
||||
@EventListener(ApplicationReadyEvent::class)
|
||||
fun onApplicationReady() {
|
||||
logger.info("订单状态更新服务已启动,将每5秒轮询一次")
|
||||
@@ -269,44 +266,66 @@ class OrderStatusUpdateService(
|
||||
orderNullDetectionTime.getOrPut(order.buyOrderId) { System.currentTimeMillis() }
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
// 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
|
||||
val hasMatchedDetails =
|
||||
sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
|
||||
if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
|
||||
// 检查是否超过清理时间窗口(1小时)
|
||||
val orderAge = currentTime - order.createdAt
|
||||
if (orderAge >= PARTIAL_SOLD_CLEANUP_WINDOW_MS) {
|
||||
logger.warn("订单详情为 null 且已部分卖出,但超过清理时间窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${orderAge / 1000}s")
|
||||
// 检查订单是否已经通过订单详情更正过数据并发送过通知
|
||||
if (order.notificationSent) {
|
||||
// 检查是否超过重试时间窗口
|
||||
if (currentTime - firstDetectionTime >= ORDER_NULL_RETRY_WINDOW_MS) {
|
||||
// 超过60秒,将订单状态改为 fully_matched,不再查询
|
||||
logger.info("订单已发送通知且详情为 null 超过60秒,标记为 fully_matched: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
try {
|
||||
copyOrderTrackingRepository.deleteById(order.id!!)
|
||||
logger.info("已删除本地订单(超时清理): orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
// 清除缓存
|
||||
val updatedOrder = CopyOrderTracking(
|
||||
id = order.id,
|
||||
copyTradingId = order.copyTradingId,
|
||||
accountId = order.accountId,
|
||||
leaderId = order.leaderId,
|
||||
marketId = order.marketId,
|
||||
side = order.side,
|
||||
outcomeIndex = order.outcomeIndex,
|
||||
buyOrderId = order.buyOrderId,
|
||||
leaderBuyTradeId = order.leaderBuyTradeId,
|
||||
quantity = order.quantity,
|
||||
price = order.price,
|
||||
matchedQuantity = order.matchedQuantity,
|
||||
remainingQuantity = order.remainingQuantity,
|
||||
status = "fully_matched", // 标记为完全匹配
|
||||
notificationSent = order.notificationSent,
|
||||
source = order.source,
|
||||
createdAt = order.createdAt,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
copyOrderTrackingRepository.save(updatedOrder)
|
||||
// 清除缓存(仅在处理完成后清除)
|
||||
orderNullDetectionTime.remove(order.buyOrderId)
|
||||
} catch (e: Exception) {
|
||||
logger.error(
|
||||
"删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}",
|
||||
e
|
||||
)
|
||||
logger.error("更新订单状态失败: orderId=${order.buyOrderId}, error=${e.message}", e)
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
logger.debug("订单详情为 null 但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${orderAge / 1000}s")
|
||||
// 清除缓存,下次重新检测
|
||||
orderNullDetectionTime.remove(order.buyOrderId)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否超过重试时间窗口
|
||||
if (currentTime - firstDetectionTime < ORDER_NULL_RETRY_WINDOW_MS) {
|
||||
// 未超过重试窗口,记录日志并等待下次轮询
|
||||
val elapsedSeconds = ((currentTime - firstDetectionTime) / 1000).toInt()
|
||||
logger.debug("订单详情为 null(可能是网络异常),等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
|
||||
// 未超过60秒,继续等待,不清除缓存
|
||||
continue
|
||||
}
|
||||
|
||||
// 超过重试窗口,删除本地订单
|
||||
logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
|
||||
// 检查是否超过重试时间窗口(统一使用60秒,无论是否已部分卖出)
|
||||
if (currentTime - firstDetectionTime < ORDER_NULL_RETRY_WINDOW_MS) {
|
||||
// 未超过重试窗口,记录日志并等待下次轮询
|
||||
val elapsedSeconds = ((currentTime - firstDetectionTime) / 1000).toInt()
|
||||
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
|
||||
val hasPartialSold = hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO
|
||||
if (hasPartialSold) {
|
||||
logger.debug("订单详情为 null 且已部分卖出,等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
|
||||
} else {
|
||||
logger.debug("订单详情为 null(可能是网络异常),等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 超过重试窗口,删除本地订单(无论是否已部分卖出)
|
||||
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
|
||||
val hasPartialSold = hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO
|
||||
if (hasPartialSold) {
|
||||
logger.warn("订单详情为 null 且已部分卖出,超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
|
||||
} else {
|
||||
logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
|
||||
}
|
||||
try {
|
||||
copyOrderTrackingRepository.deleteById(order.id!!)
|
||||
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
@@ -710,43 +729,66 @@ class OrderStatusUpdateService(
|
||||
orderNullDetectionTime.getOrPut(order.buyOrderId) { System.currentTimeMillis() }
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
// 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
|
||||
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
|
||||
if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
|
||||
// 检查是否超过清理时间窗口(1小时)
|
||||
val orderAge = currentTime - order.createdAt
|
||||
if (orderAge >= PARTIAL_SOLD_CLEANUP_WINDOW_MS) {
|
||||
logger.warn("订单详情为 null 且已部分卖出,但超过清理时间窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${orderAge / 1000}s")
|
||||
// 检查订单是否已经通过订单详情更正过数据并发送过通知
|
||||
if (order.notificationSent) {
|
||||
// 检查是否超过重试时间窗口
|
||||
if (currentTime - firstDetectionTime >= ORDER_NULL_RETRY_WINDOW_MS) {
|
||||
// 超过60秒,将订单状态改为 fully_matched,不再查询
|
||||
logger.info("订单已发送通知且详情为 null 超过60秒,标记为 fully_matched: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
try {
|
||||
copyOrderTrackingRepository.deleteById(order.id!!)
|
||||
logger.info("已删除本地订单(超时清理): orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
// 清除缓存
|
||||
val updatedOrder = CopyOrderTracking(
|
||||
id = order.id,
|
||||
copyTradingId = order.copyTradingId,
|
||||
accountId = order.accountId,
|
||||
leaderId = order.leaderId,
|
||||
marketId = order.marketId,
|
||||
side = order.side,
|
||||
outcomeIndex = order.outcomeIndex,
|
||||
buyOrderId = order.buyOrderId,
|
||||
leaderBuyTradeId = order.leaderBuyTradeId,
|
||||
quantity = order.quantity,
|
||||
price = order.price,
|
||||
matchedQuantity = order.matchedQuantity,
|
||||
remainingQuantity = order.remainingQuantity,
|
||||
status = "fully_matched", // 标记为完全匹配
|
||||
notificationSent = order.notificationSent,
|
||||
source = order.source,
|
||||
createdAt = order.createdAt,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
copyOrderTrackingRepository.save(updatedOrder)
|
||||
// 清除缓存(仅在处理完成后清除)
|
||||
orderNullDetectionTime.remove(order.buyOrderId)
|
||||
} catch (e: Exception) {
|
||||
logger.error(
|
||||
"删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}",
|
||||
e
|
||||
)
|
||||
logger.error("更新订单状态失败: orderId=${order.buyOrderId}, error=${e.message}", e)
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
logger.debug("订单详情为 null 但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${orderAge / 1000}s")
|
||||
// 清除缓存,下次重新检测
|
||||
orderNullDetectionTime.remove(order.buyOrderId)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否超过重试时间窗口
|
||||
if (currentTime - firstDetectionTime < ORDER_NULL_RETRY_WINDOW_MS) {
|
||||
// 未超过重试窗口,记录日志并等待下次轮询
|
||||
val elapsedSeconds = ((currentTime - firstDetectionTime) / 1000).toInt()
|
||||
logger.debug("订单详情为 null(可能是网络异常),等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
|
||||
// 未超过60秒,继续等待,不清除缓存
|
||||
continue
|
||||
}
|
||||
|
||||
// 超过重试窗口,删除本地订单
|
||||
logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000)s")
|
||||
// 检查是否超过重试时间窗口(统一使用60秒,无论是否已部分卖出)
|
||||
if (currentTime - firstDetectionTime < ORDER_NULL_RETRY_WINDOW_MS) {
|
||||
// 未超过重试窗口,记录日志并等待下次轮询
|
||||
val elapsedSeconds = ((currentTime - firstDetectionTime) / 1000).toInt()
|
||||
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
|
||||
val hasPartialSold = hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO
|
||||
if (hasPartialSold) {
|
||||
logger.debug("订单详情为 null 且已部分卖出,等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
|
||||
} else {
|
||||
logger.debug("订单详情为 null(可能是网络异常),等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 超过重试窗口,删除本地订单(无论是否已部分卖出)
|
||||
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
|
||||
val hasPartialSold = hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO
|
||||
if (hasPartialSold) {
|
||||
logger.warn("订单详情为 null 且已部分卖出,超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
|
||||
} else {
|
||||
logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
|
||||
}
|
||||
try {
|
||||
copyOrderTrackingRepository.deleteById(order.id!!)
|
||||
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
|
||||
+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,
|
||||
|
||||
@@ -98,8 +98,8 @@ class RpcNodeService(
|
||||
.filterNot { isDefaultNode(it) } // 排除默认节点
|
||||
|
||||
if (nodes.isEmpty()) {
|
||||
logger.warn("没有配置任何 RPC 节点,使用默认节点: $DEFAULT_RPC_URL")
|
||||
return Result.failure(IllegalStateException("没有配置任何 RPC 节点"))
|
||||
logger.warn("没有配置任何启用的 RPC 节点,将使用默认节点")
|
||||
return Result.success(createDefaultNodeConfig())
|
||||
}
|
||||
|
||||
// 优先使用最近检查状态为 HEALTHY 的节点
|
||||
@@ -136,39 +136,53 @@ class RpcNodeService(
|
||||
}
|
||||
}
|
||||
|
||||
// 所有节点都不可用,返回失败
|
||||
logger.warn("所有 RPC 节点都不可用,将使用默认节点: $DEFAULT_RPC_URL")
|
||||
Result.failure(IllegalStateException("所有 RPC 节点都不可用"))
|
||||
// 所有节点都不可用,返回默认节点
|
||||
logger.warn("所有启用的 RPC 节点都不可用,将使用默认节点: $DEFAULT_RPC_URL")
|
||||
Result.success(createDefaultNodeConfig())
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取可用节点失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
// 即使失败也返回默认节点,确保系统可用
|
||||
logger.warn("获取可用节点出现异常,使用默认节点作为兜底")
|
||||
Result.success(createDefaultNodeConfig())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认节点配置
|
||||
* 用于兜底,确保系统始终有可用的 RPC 节点
|
||||
*/
|
||||
private fun createDefaultNodeConfig(): RpcNodeConfig {
|
||||
return RpcNodeConfig(
|
||||
id = 0L,
|
||||
providerType = RpcProviderType.PUBLIC.name,
|
||||
name = "默认节点",
|
||||
httpUrl = DEFAULT_RPC_URL,
|
||||
wsUrl = DEFAULT_WS_URL,
|
||||
apiKey = null,
|
||||
enabled = true,
|
||||
priority = 9999,
|
||||
lastCheckTime = System.currentTimeMillis(),
|
||||
lastCheckStatus = NodeHealthStatus.HEALTHY.name,
|
||||
responseTimeMs = null,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的 HTTP URL (如果没有配置,使用默认节点)
|
||||
*/
|
||||
fun getHttpUrl(): String {
|
||||
val nodeResult = getAvailableNode()
|
||||
return if (nodeResult.isSuccess) {
|
||||
nodeResult.getOrNull()?.httpUrl ?: DEFAULT_RPC_URL
|
||||
} else {
|
||||
logger.warn("没有可用的用户配置节点,使用默认节点")
|
||||
DEFAULT_RPC_URL
|
||||
}
|
||||
val node = getAvailableNode().getOrNull()
|
||||
return node?.httpUrl ?: DEFAULT_RPC_URL
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的 WebSocket URL (如果没有配置,使用默认节点)
|
||||
*/
|
||||
fun getWsUrl(): String {
|
||||
val nodeResult = getAvailableNode()
|
||||
return if (nodeResult.isSuccess) {
|
||||
nodeResult.getOrNull()?.wsUrl ?: DEFAULT_WS_URL
|
||||
} else {
|
||||
logger.warn("没有可用的用户配置节点,使用默认 WS 节点")
|
||||
DEFAULT_WS_URL
|
||||
}
|
||||
val node = getAvailableNode().getOrNull()
|
||||
return node?.wsUrl ?: DEFAULT_WS_URL
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,6 +277,13 @@ class RpcNodeService(
|
||||
return Result.failure(IllegalArgumentException("默认节点不允许更新"))
|
||||
}
|
||||
|
||||
// 检查是否禁用节点,如果是则清理缓存
|
||||
val isDisabling = request.enabled == false && node.enabled == true
|
||||
if (isDisabling) {
|
||||
logger.info("节点被禁用,清理 RPC 缓存: ${node.httpUrl}")
|
||||
retrofitFactory.clearRpcApiCache(node.httpUrl)
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
val updatedNode = node.copy(
|
||||
name = request.name ?: node.name,
|
||||
@@ -272,7 +293,7 @@ class RpcNodeService(
|
||||
)
|
||||
|
||||
val savedNode = rpcNodeConfigRepository.save(updatedNode)
|
||||
logger.info("成功更新 RPC 节点: ${savedNode.name}")
|
||||
logger.info("成功更新 RPC 节点: ${savedNode.name}, enabled=${savedNode.enabled}")
|
||||
Result.success(savedNode)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新节点失败: ${e.message}", e)
|
||||
@@ -295,6 +316,10 @@ class RpcNodeService(
|
||||
return Result.failure(IllegalArgumentException("默认节点不允许删除"))
|
||||
}
|
||||
|
||||
// 清理 RPC 缓存
|
||||
logger.info("删除节点,清理 RPC 缓存: ${node.httpUrl}")
|
||||
retrofitFactory.clearRpcApiCache(node.httpUrl)
|
||||
|
||||
rpcNodeConfigRepository.delete(node)
|
||||
logger.info("成功删除 RPC 节点: ${node.name}")
|
||||
Result.success(Unit)
|
||||
|
||||
@@ -84,5 +84,38 @@ object DateUtils {
|
||||
}
|
||||
return displayFormatter.format(instant)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将时间间隔(毫秒)格式化为可读的字符串
|
||||
* 格式:X天X小时X分钟 或 X小时X分钟 或 X分钟
|
||||
* 只显示有意义的单位,不显示0值单位
|
||||
*
|
||||
* @param milliseconds 时间间隔(毫秒)
|
||||
* @return 格式化的时间间隔字符串,如 "2天3小时15分钟"、"5小时30分钟"、"45分钟"
|
||||
*/
|
||||
fun formatDuration(milliseconds: Long): String {
|
||||
if (milliseconds < 0) {
|
||||
return "0分钟"
|
||||
}
|
||||
|
||||
val totalSeconds = milliseconds / 1000
|
||||
val days = totalSeconds / (24 * 60 * 60)
|
||||
val hours = (totalSeconds % (24 * 60 * 60)) / (60 * 60)
|
||||
val minutes = (totalSeconds % (60 * 60)) / 60
|
||||
|
||||
val parts = mutableListOf<String>()
|
||||
|
||||
if (days > 0) {
|
||||
parts.add("${days}天")
|
||||
}
|
||||
if (hours > 0) {
|
||||
parts.add("${hours}小时")
|
||||
}
|
||||
if (minutes > 0 || parts.isEmpty()) {
|
||||
parts.add("${minutes}分钟")
|
||||
}
|
||||
|
||||
return parts.joinToString("")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- ============================================
|
||||
-- V26: 移除最大仓位数量配置字段
|
||||
-- 从 copy_trading 表中删除 max_position_count 字段
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE copy_trading
|
||||
DROP COLUMN max_position_count;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
-- ============================================
|
||||
-- 回测功能表创建
|
||||
-- ============================================
|
||||
|
||||
-- ============================================
|
||||
-- 2. 创建回测任务表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS backtest_task (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '回测任务ID',
|
||||
task_name VARCHAR(100) NOT NULL COMMENT '回测任务名称',
|
||||
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
|
||||
initial_balance DECIMAL(20, 8) NOT NULL COMMENT '初始资金',
|
||||
final_balance DECIMAL(20, 8) DEFAULT NULL COMMENT '最终资金',
|
||||
profit_amount DECIMAL(20, 8) DEFAULT NULL COMMENT '收益金额',
|
||||
profit_rate DECIMAL(10, 4) DEFAULT NULL COMMENT '收益率(%)',
|
||||
backtest_days INT NOT NULL COMMENT '回测天数',
|
||||
start_time BIGINT NOT NULL COMMENT '回测开始时间(历史时间)',
|
||||
end_time BIGINT DEFAULT NULL COMMENT '回测结束时间(历史时间)',
|
||||
|
||||
-- 跟单配置 (复制CopyTrading表结构)
|
||||
copy_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '跟单模式: RATIO/FIXED',
|
||||
copy_ratio DECIMAL(20, 8) NOT NULL DEFAULT 1.0 COMMENT '跟单比例',
|
||||
fixed_amount DECIMAL(20, 8) DEFAULT NULL COMMENT '固定金额',
|
||||
max_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1000.0 COMMENT '最大单笔订单',
|
||||
min_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1.0 COMMENT '最小单笔订单',
|
||||
max_daily_loss DECIMAL(20, 8) NOT NULL DEFAULT 10000.0 COMMENT '最大每日亏损',
|
||||
max_daily_orders INT NOT NULL DEFAULT 100 COMMENT '最大每日订单数',
|
||||
price_tolerance DECIMAL(5, 2) NOT NULL DEFAULT 5.0 COMMENT '价格容忍度(%)',
|
||||
delay_seconds INT NOT NULL DEFAULT 0 COMMENT '延迟秒数',
|
||||
support_sell BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否支持卖出',
|
||||
min_order_depth DECIMAL(20, 8) DEFAULT NULL COMMENT '最小订单深度',
|
||||
max_spread DECIMAL(20, 8) DEFAULT NULL COMMENT '最大价差',
|
||||
min_price DECIMAL(20, 8) DEFAULT NULL COMMENT '最低价格',
|
||||
max_price DECIMAL(20, 8) DEFAULT NULL COMMENT '最高价格',
|
||||
max_position_value DECIMAL(20, 8) DEFAULT NULL COMMENT '最大仓位金额',
|
||||
keyword_filter_mode VARCHAR(20) NOT NULL DEFAULT 'DISABLED' COMMENT '关键字过滤模式',
|
||||
keywords JSON DEFAULT NULL COMMENT '关键字列表',
|
||||
max_market_end_date BIGINT DEFAULT NULL COMMENT '市场截止时间限制',
|
||||
|
||||
-- 统计字段
|
||||
avg_holding_time BIGINT DEFAULT NULL COMMENT '平均持仓时间(毫秒)',
|
||||
data_source VARCHAR(50) DEFAULT 'MIXED' COMMENT '数据源: INTERNAL/API/MIXED',
|
||||
|
||||
-- 执行状态
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态: PENDING/RUNNING/COMPLETED/STOPPED/FAILED',
|
||||
progress INT DEFAULT 0 COMMENT '执行进度(0-100)',
|
||||
total_trades INT DEFAULT 0 COMMENT '总交易笔数',
|
||||
buy_trades INT DEFAULT 0 COMMENT '买入笔数',
|
||||
sell_trades INT DEFAULT 0 COMMENT '卖出笔数',
|
||||
win_trades INT DEFAULT 0 COMMENT '盈利交易笔数',
|
||||
loss_trades INT DEFAULT 0 COMMENT '亏损交易笔数',
|
||||
win_rate DECIMAL(5, 2) DEFAULT NULL COMMENT '胜率(%)',
|
||||
max_profit DECIMAL(20, 8) DEFAULT NULL COMMENT '最大单笔盈利',
|
||||
max_loss DECIMAL(20, 8) DEFAULT NULL COMMENT '最大单笔亏损',
|
||||
max_drawdown DECIMAL(20, 8) DEFAULT NULL COMMENT '最大回撤',
|
||||
error_message TEXT DEFAULT NULL COMMENT '错误信息',
|
||||
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
execution_started_at BIGINT DEFAULT NULL COMMENT '执行开始时间(系统时间)',
|
||||
execution_finished_at BIGINT DEFAULT NULL COMMENT '执行完成时间(系统时间)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间',
|
||||
|
||||
INDEX idx_leader_id (leader_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_leader_profit (leader_id, profit_rate DESC),
|
||||
INDEX idx_status_created (status, created_at DESC),
|
||||
FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回测任务表';
|
||||
|
||||
-- ============================================
|
||||
-- 3. 创建回测交易记录表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS backtest_trade (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '交易记录ID',
|
||||
backtest_task_id BIGINT NOT NULL COMMENT '回测任务ID',
|
||||
trade_time BIGINT NOT NULL COMMENT '交易时间',
|
||||
market_id VARCHAR(100) NOT NULL COMMENT '市场ID',
|
||||
market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题',
|
||||
side VARCHAR(20) NOT NULL COMMENT '方向: BUY/SELL/SETTLEMENT',
|
||||
outcome VARCHAR(50) NOT NULL COMMENT '结果: YES/NO或outcomeIndex',
|
||||
outcome_index INT DEFAULT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场',
|
||||
quantity DECIMAL(20, 8) NOT NULL COMMENT '数量',
|
||||
price DECIMAL(20, 8) NOT NULL COMMENT '价格',
|
||||
amount DECIMAL(20, 8) NOT NULL COMMENT '金额',
|
||||
fee DECIMAL(20, 8) NOT NULL DEFAULT 0.0 COMMENT '手续费',
|
||||
profit_loss DECIMAL(20, 8) DEFAULT NULL COMMENT '盈亏(仅卖出时)',
|
||||
balance_after DECIMAL(20, 8) NOT NULL COMMENT '交易后余额',
|
||||
leader_trade_id VARCHAR(100) DEFAULT NULL COMMENT 'Leader原始交易ID',
|
||||
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
|
||||
INDEX idx_backtest_task_id (backtest_task_id),
|
||||
INDEX idx_trade_time (trade_time),
|
||||
FOREIGN KEY (backtest_task_id) REFERENCES backtest_task(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回测交易记录表';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- ============================================
|
||||
-- 回测功能恢复字段添加
|
||||
-- ============================================
|
||||
|
||||
-- 添加恢复相关字段到回测任务表
|
||||
ALTER TABLE backtest_task
|
||||
ADD COLUMN last_processed_trade_time BIGINT DEFAULT NULL COMMENT '最后处理的交易时间(用于中断恢复)',
|
||||
ADD COLUMN last_processed_trade_index INT DEFAULT 0 COMMENT '最后处理的交易索引(用于中断恢复)',
|
||||
ADD COLUMN processed_trade_count INT DEFAULT 0 COMMENT '已处理的交易数量(用于显示真实进度)';
|
||||
|
||||
-- 添加索引以优化查询性能
|
||||
ALTER TABLE backtest_task
|
||||
ADD INDEX idx_last_processed_trade_time (last_processed_trade_time);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Drop unused columns from backtest_task table
|
||||
-- These fields are not needed for backtest scenarios as they use historical data
|
||||
-- Note: Using standard SQL syntax compatible with MySQL 5.7+
|
||||
|
||||
-- Check if columns exist before dropping (using standard approach)
|
||||
ALTER TABLE backtest_task DROP COLUMN price_tolerance;
|
||||
ALTER TABLE backtest_task DROP COLUMN delay_seconds;
|
||||
ALTER TABLE backtest_task DROP COLUMN min_order_depth;
|
||||
ALTER TABLE backtest_task DROP COLUMN max_spread;
|
||||
ALTER TABLE backtest_task DROP COLUMN min_price;
|
||||
ALTER TABLE backtest_task DROP COLUMN max_price;
|
||||
ALTER TABLE backtest_task DROP COLUMN max_position_value;
|
||||
ALTER TABLE backtest_task DROP COLUMN max_market_end_date;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- ============================================
|
||||
-- 修复回测恢复逻辑:将 last_processed_trade_index 默认值改为 NULL
|
||||
-- ============================================
|
||||
-- 问题:新建任务的 last_processed_trade_index 默认值为 0,导致被误判为恢复任务
|
||||
-- 解决:将默认值改为 NULL,并将现有新任务的 0 值改为 NULL
|
||||
|
||||
-- 1. 将现有新任务(status='PENDING' 且 last_processed_trade_index=0)的索引值改为 NULL
|
||||
UPDATE backtest_task
|
||||
SET last_processed_trade_index = NULL
|
||||
WHERE status = 'PENDING' AND last_processed_trade_index = 0;
|
||||
|
||||
-- 2. 修改字段定义,允许 NULL 并设置默认值为 NULL
|
||||
ALTER TABLE backtest_task
|
||||
MODIFY COLUMN last_processed_trade_index INT DEFAULT NULL COMMENT '最后处理的交易索引(用于中断恢复)';
|
||||
|
||||
@@ -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;
|
||||
@@ -255,3 +255,56 @@ error.server.order_tracking_process_failed=Failed to process order tracking
|
||||
error.server.order_tracking_buy_failed=Failed to process buy order
|
||||
error.server.order_tracking_sell_failed=Failed to process sell order
|
||||
error.server.order_tracking_match_failed=Order matching failed
|
||||
|
||||
# Backtest service errors
|
||||
error.backtest.task_not_found=Backtest task not found
|
||||
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
|
||||
error.server.backtest_list_fetch_failed=Failed to fetch backtest list
|
||||
error.server.backtest_detail_fetch_failed=Failed to fetch backtest detail
|
||||
error.server.backtest_trades_fetch_failed=Failed to fetch backtest trades
|
||||
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
|
||||
backtest.task_name=Task Name
|
||||
backtest.leader=Leader
|
||||
backtest.initial_balance=Initial Balance
|
||||
backtest.backtest_days=Backtest Days
|
||||
backtest.profit_amount=Profit Amount
|
||||
backtest.profit_rate=Profit Rate
|
||||
backtest.backtest_days_range=Backtest Days Range (1-15 days)
|
||||
backtest.total_trades=Total Trades
|
||||
backtest.buy_trades=Buy Trades
|
||||
backtest.sell_trades=Sell Trades
|
||||
backtest.win_trades=Win Trades
|
||||
backtest.loss_trades=Loss Trades
|
||||
backtest.win_rate=Win Rate
|
||||
backtest.max_profit=Max Profit
|
||||
backtest.max_loss=Max Loss
|
||||
backtest.max_drawdown=Max Drawdown
|
||||
backtest.avg_holding_time=Avg Holding Time
|
||||
|
||||
# Backtest Status
|
||||
backtest.status.pending=Pending
|
||||
backtest.status.running=Running
|
||||
backtest.status.completed=Completed
|
||||
backtest.status.stopped=Stopped
|
||||
backtest.status.failed=Failed
|
||||
|
||||
# Backtest Config
|
||||
backtest.copy_mode.ratio=Ratio Mode
|
||||
backtest.copy_mode.fixed=Fixed Amount
|
||||
backtest.price_tolerance=Price Tolerance
|
||||
backtest.delay_seconds=Delay Seconds
|
||||
backtest.support_sell=Support Sell
|
||||
|
||||
@@ -255,3 +255,62 @@ error.server.order_tracking_process_failed=处理订单跟踪失败
|
||||
error.server.order_tracking_buy_failed=处理买入订单失败
|
||||
error.server.order_tracking_sell_failed=处理卖出订单失败
|
||||
error.server.order_tracking_match_failed=订单匹配失败
|
||||
|
||||
# 回测服务错误
|
||||
error.backtest.task_not_found=回测任务不存在
|
||||
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=删除回测任务失败
|
||||
error.server.backtest_list_fetch_failed=查询回测列表失败
|
||||
error.server.backtest_detail_fetch_failed=查询回测详情失败
|
||||
error.server.backtest_trades_fetch_failed=查询回测交易记录失败
|
||||
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=新增回测
|
||||
backtest.task_name=回测名称
|
||||
backtest.leader=Leader
|
||||
backtest.initial_balance=初始金额
|
||||
backtest.backtest_days=回测天数
|
||||
backtest.profit_amount=收益额
|
||||
backtest.profit_rate=收益率
|
||||
backtest.backtest_days_range=回测天数范围(1-15天)
|
||||
backtest.total_trades=总交易笔数
|
||||
backtest.buy_trades=买入笔数
|
||||
backtest.sell_trades=卖出笔数
|
||||
backtest.win_trades=盈利交易笔数
|
||||
backtest.loss_trades=亏损交易笔数
|
||||
backtest.win_rate=胜率
|
||||
backtest.max_profit=最大单笔盈利
|
||||
backtest.max_loss=最大单笔亏损
|
||||
backtest.max_drawdown=最大回撤
|
||||
backtest.avg_holding_time=平均持仓时间
|
||||
|
||||
# 回测状态
|
||||
backtest.status.pending=待执行
|
||||
backtest.status.running=运行中
|
||||
backtest.status.completed=已完成
|
||||
backtest.status.stopped=已停止
|
||||
backtest.status.failed=失败
|
||||
|
||||
# 回测配置
|
||||
backtest.copy_mode.ratio=比例模式
|
||||
backtest.copy_mode.fixed=固定金额
|
||||
backtest.price_tolerance=价格容忍度
|
||||
backtest.delay_seconds=延迟秒数
|
||||
backtest.support_sell=支持卖出
|
||||
|
||||
# 订单跟踪服务错误
|
||||
error.server.order_tracking_process_failed=处理订单跟踪失败
|
||||
error.server.order_tracking_buy_failed=处理买入订单失败
|
||||
error.server.order_tracking_sell_failed=处理卖出订单失败
|
||||
error.server.order_tracking_match_failed=订单匹配失败
|
||||
|
||||
@@ -255,3 +255,56 @@ error.server.order_tracking_process_failed=處理訂單跟蹤失敗
|
||||
error.server.order_tracking_buy_failed=處理買入訂單失敗
|
||||
error.server.order_tracking_sell_failed=處理賣出訂單失敗
|
||||
error.server.order_tracking_match_failed=訂單匹配失敗
|
||||
|
||||
# 回測服務錯誤
|
||||
error.backtest.task_not_found=回測任務不存在
|
||||
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=刪除回測任務失敗
|
||||
error.server.backtest_list_fetch_failed=查詢回測列表失敗
|
||||
error.server.backtest_detail_fetch_failed=查詢回測詳情失敗
|
||||
error.server.backtest_trades_fetch_failed=查詢回測交易記錄失敗
|
||||
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=新增回測
|
||||
backtest.task_name=回測名稱
|
||||
backtest.leader=Leader
|
||||
backtest.initial_balance=初始金額
|
||||
backtest.backtest_days=回測天數
|
||||
backtest.profit_amount=收益額
|
||||
backtest.profit_rate=收益率
|
||||
backtest.backtest_days_range=回測天數範圍(1-15天)
|
||||
backtest.total_trades=總交易筆數
|
||||
backtest.buy_trades=買入筆數
|
||||
backtest.sell_trades=賣出筆數
|
||||
backtest.win_trades=盈利交易筆數
|
||||
backtest.loss_trades=虧損交易筆數
|
||||
backtest.win_rate=勝率
|
||||
backtest.max_profit=最大單筆盈利
|
||||
backtest.max_loss=最大單筆虧損
|
||||
backtest.max_drawdown=最大回撤
|
||||
backtest.avg_holding_time=平均持倉時間
|
||||
|
||||
# 回測狀態
|
||||
backtest.status.pending=待執行
|
||||
backtest.status.running=運行中
|
||||
backtest.status.completed=已完成
|
||||
backtest.status.stopped=已停止
|
||||
backtest.status.failed=失敗
|
||||
|
||||
# 回測配置
|
||||
backtest.copy_mode.ratio=比例模式
|
||||
backtest.copy_mode.fixed=固定金額
|
||||
backtest.price_tolerance=價格容忍度
|
||||
backtest.delay_seconds=延遲秒數
|
||||
backtest.support_sell=支持賣出
|
||||
|
||||
Executable
+349
@@ -0,0 +1,349 @@
|
||||
#!/bin/bash
|
||||
|
||||
# PolyHermes Release 创建脚本
|
||||
# 功能:创建 tag、推送 tag、创建 GitHub Release(支持 pre-release)
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 打印信息
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# 显示使用说明
|
||||
usage() {
|
||||
cat << EOF
|
||||
用法: $0 [选项]
|
||||
|
||||
选项:
|
||||
-t, --tag TAG 版本号 tag(必需,格式:v1.0.0)
|
||||
-T, --title TITLE Release 标题(可选,默认使用 tag)
|
||||
-d, --description DESC Release 描述内容(可选)
|
||||
-f, --description-file FILE 从文件读取 Release 描述(可选)
|
||||
-p, --prerelease 标记为 Pre-release(会自动拼接 -beta 后缀,默认:false)
|
||||
-y, --yes 无交互模式,自动确认所有操作(默认:false)
|
||||
-h, --help 显示此帮助信息
|
||||
|
||||
示例:
|
||||
# 创建正式版本
|
||||
$0 -t v1.0.1 -T "Release v1.0.1" -d "## 新功能\n- 功能1\n- 功能2"
|
||||
|
||||
# 创建 Pre-release(自动拼接 -beta)
|
||||
$0 -t v1.0.1 -T "Release v1.0.1-beta" -d "测试版本" --prerelease
|
||||
# 实际创建的 tag: v1.0.1-beta
|
||||
|
||||
# 从文件读取描述
|
||||
$0 -t v1.0.1 -f CHANGELOG.md --prerelease
|
||||
# 实际创建的 tag: v1.0.1-beta
|
||||
|
||||
# 无交互模式(适合 CI/CD 或自动化脚本)
|
||||
$0 -t v1.0.1 -d "更新内容" --yes
|
||||
|
||||
版本号格式:
|
||||
- 必须格式: v数字.数字.数字 (例如: v1.0.0, v1.10.2, v1.1.12)
|
||||
- 如果指定 --prerelease,会自动拼接 -beta 后缀 (例如: v1.0.1 -> v1.0.1-beta)
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# 验证版本号格式(只允许 v数字.数字.数字,不允许后缀)
|
||||
validate_tag() {
|
||||
local tag=$1
|
||||
# 匹配格式:v数字.数字.数字(不允许后缀)
|
||||
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
error "版本号格式不正确:$tag"
|
||||
error "应为 v数字.数字.数字 (例如: v1.0.0, v1.10.2, v1.1.12)"
|
||||
error "如果创建 Pre-release,请使用 --prerelease 参数,脚本会自动拼接 -beta 后缀"
|
||||
exit 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# 检查必要的工具
|
||||
check_requirements() {
|
||||
local auto_yes=$1
|
||||
|
||||
# 检查 git
|
||||
if ! command -v git &> /dev/null; then
|
||||
error "未找到 git 命令,请先安装 git"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 GitHub CLI
|
||||
if ! command -v gh &> /dev/null; then
|
||||
error "未找到 GitHub CLI (gh) 命令"
|
||||
error "请先安装 GitHub CLI: https://cli.github.com/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查是否已登录 GitHub
|
||||
if ! gh auth status &> /dev/null; then
|
||||
error "未登录 GitHub,请先运行: gh auth login"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查是否有未提交的更改
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
warn "检测到未提交的更改,建议先提交或暂存"
|
||||
if [[ "$auto_yes" == "true" ]]; then
|
||||
info "无交互模式:自动继续"
|
||||
else
|
||||
read -p "是否继续?(y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
info "已取消"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查是否在正确的分支
|
||||
local current_branch=$(git branch --show-current)
|
||||
info "当前分支: $current_branch"
|
||||
}
|
||||
|
||||
# 检查 tag 是否已存在
|
||||
check_tag_exists() {
|
||||
local tag=$1
|
||||
local auto_yes=$2
|
||||
|
||||
if git rev-parse "$tag" >/dev/null 2>&1; then
|
||||
error "Tag $tag 已存在(本地)"
|
||||
if [[ "$auto_yes" == "true" ]]; then
|
||||
info "无交互模式:自动删除并重新创建"
|
||||
git tag -d "$tag" || true
|
||||
git push origin ":refs/tags/$tag" || true
|
||||
info "已删除旧 tag: $tag"
|
||||
else
|
||||
read -p "是否删除并重新创建?(y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
git tag -d "$tag" || true
|
||||
git push origin ":refs/tags/$tag" || true
|
||||
info "已删除旧 tag: $tag"
|
||||
else
|
||||
error "已取消"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查远程是否存在
|
||||
if git ls-remote --tags origin "$tag" | grep -q "$tag"; then
|
||||
error "Tag $tag 已存在于远程仓库"
|
||||
if [[ "$auto_yes" == "true" ]]; then
|
||||
info "无交互模式:自动删除并重新创建"
|
||||
git tag -d "$tag" || true
|
||||
git push origin ":refs/tags/$tag" || true
|
||||
info "已删除远程 tag: $tag"
|
||||
else
|
||||
read -p "是否删除并重新创建?(y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
git tag -d "$tag" || true
|
||||
git push origin ":refs/tags/$tag" || true
|
||||
info "已删除远程 tag: $tag"
|
||||
else
|
||||
error "已取消"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
local TAG=""
|
||||
local TITLE=""
|
||||
local DESCRIPTION=""
|
||||
local DESCRIPTION_FILE=""
|
||||
local PRERELEASE=false
|
||||
local AUTO_YES=false
|
||||
|
||||
# 解析参数
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-t|--tag)
|
||||
TAG="$2"
|
||||
shift 2
|
||||
;;
|
||||
-T|--title)
|
||||
TITLE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-d|--description)
|
||||
DESCRIPTION="$2"
|
||||
shift 2
|
||||
;;
|
||||
-f|--description-file)
|
||||
DESCRIPTION_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-p|--prerelease)
|
||||
PRERELEASE=true
|
||||
shift
|
||||
;;
|
||||
-y|--yes)
|
||||
AUTO_YES=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
error "未知参数: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 检查必需参数
|
||||
if [[ -z "$TAG" ]]; then
|
||||
error "缺少必需参数: --tag"
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 验证版本号格式(不允许后缀)
|
||||
validate_tag "$TAG"
|
||||
|
||||
# 如果指定了 --prerelease,自动拼接 -beta 后缀
|
||||
local BASE_TAG="$TAG"
|
||||
if [[ "$PRERELEASE" == "true" ]]; then
|
||||
TAG="${BASE_TAG}-beta"
|
||||
info "Pre-release 模式:tag 将自动拼接 -beta 后缀"
|
||||
info "基础版本: $BASE_TAG -> 实际 tag: $TAG"
|
||||
fi
|
||||
|
||||
# 检查工具和环境
|
||||
check_requirements "$AUTO_YES"
|
||||
|
||||
# 检查 tag 是否已存在(使用拼接后的 tag)
|
||||
check_tag_exists "$TAG" "$AUTO_YES"
|
||||
|
||||
# 设置默认标题
|
||||
if [[ -z "$TITLE" ]]; then
|
||||
TITLE="$TAG"
|
||||
fi
|
||||
|
||||
# 读取描述内容
|
||||
if [[ -n "$DESCRIPTION_FILE" ]]; then
|
||||
if [[ ! -f "$DESCRIPTION_FILE" ]]; then
|
||||
error "描述文件不存在: $DESCRIPTION_FILE"
|
||||
exit 1
|
||||
fi
|
||||
DESCRIPTION=$(cat "$DESCRIPTION_FILE")
|
||||
fi
|
||||
|
||||
# 如果没有描述,使用默认值
|
||||
if [[ -z "$DESCRIPTION" ]]; then
|
||||
if [[ "$PRERELEASE" == "true" ]]; then
|
||||
DESCRIPTION="Pre-release $TAG"
|
||||
else
|
||||
DESCRIPTION="Release $TAG"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 显示即将执行的操作
|
||||
echo
|
||||
info "========================================="
|
||||
info " PolyHermes Release 创建"
|
||||
info "========================================="
|
||||
info "Tag: $TAG"
|
||||
info "Title: $TITLE"
|
||||
info "Pre-release: $PRERELEASE"
|
||||
if [[ "$AUTO_YES" == "true" ]]; then
|
||||
info "模式: 无交互模式(自动确认)"
|
||||
fi
|
||||
info "Description:"
|
||||
echo "$DESCRIPTION" | sed 's/^/ /'
|
||||
info "========================================="
|
||||
echo
|
||||
|
||||
# 确认操作
|
||||
if [[ "$AUTO_YES" == "true" ]]; then
|
||||
info "无交互模式:自动确认创建 Release"
|
||||
else
|
||||
read -p "确认创建 Release?(y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
info "已取消"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. 创建 tag(基于当前 HEAD)
|
||||
info "创建 tag: $TAG"
|
||||
git tag "$TAG"
|
||||
success "Tag 创建成功: $TAG"
|
||||
|
||||
# 2. 推送 tag 到远程
|
||||
info "推送 tag 到远程..."
|
||||
git push origin "$TAG"
|
||||
success "Tag 推送成功"
|
||||
|
||||
# 3. 创建 GitHub Release
|
||||
info "创建 GitHub Release..."
|
||||
|
||||
local RELEASE_ARGS=(
|
||||
"$TAG"
|
||||
--title "$TITLE"
|
||||
--notes "$DESCRIPTION"
|
||||
)
|
||||
|
||||
if [[ "$PRERELEASE" == "true" ]]; then
|
||||
RELEASE_ARGS+=(--prerelease)
|
||||
fi
|
||||
|
||||
if gh release create "${RELEASE_ARGS[@]}"; then
|
||||
success "GitHub Release 创建成功!"
|
||||
|
||||
# 获取 release URL
|
||||
local RELEASE_URL=$(gh release view "$TAG" --json url -q .url)
|
||||
info "Release URL: $RELEASE_URL"
|
||||
|
||||
echo
|
||||
success "========================================="
|
||||
success " Release 创建完成!"
|
||||
success "========================================="
|
||||
success "Tag: $TAG"
|
||||
success "Pre-release: $PRERELEASE"
|
||||
success "URL: $RELEASE_URL"
|
||||
success "========================================="
|
||||
echo
|
||||
info "GitHub Actions 将自动触发构建流程"
|
||||
|
||||
if [[ "$PRERELEASE" == "true" ]]; then
|
||||
warn "这是 Pre-release,GitHub Actions 不会发送 Telegram 通知"
|
||||
fi
|
||||
else
|
||||
error "GitHub Release 创建失败"
|
||||
error "请手动在 GitHub 上创建 Release: https://github.com/WrBug/PolyHermes/releases/new"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
|
||||
@@ -0,0 +1,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 "$@"
|
||||
@@ -168,6 +168,10 @@ deploy() {
|
||||
|
||||
info "构建 Docker 镜像(本地构建,版本号: ${DOCKER_VERSION})..."
|
||||
|
||||
# 创建占位符目录(如果不存在),避免 Dockerfile COPY 失败
|
||||
# 当 BUILD_IN_DOCKER=true 时,backend/build 可能不存在
|
||||
mkdir -p backend/build/libs
|
||||
|
||||
# 设置构建参数(通过环境变量传递给 docker-compose.yml)
|
||||
export VERSION=${DOCKER_VERSION}
|
||||
export GIT_TAG=${DOCKER_VERSION}
|
||||
|
||||
@@ -34,6 +34,9 @@ services:
|
||||
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
|
||||
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
|
||||
# 动态更新配置
|
||||
- ALLOW_PRERELEASE=${ALLOW_PRERELEASE:-false}
|
||||
- GITHUB_REPO=${GITHUB_REPO:-WrBug/PolyHermes}
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
depends_on:
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
# 使用测试镜像(pre-release)
|
||||
image: wrbug/polyhermes:test
|
||||
container_name: polyhermes-test
|
||||
ports:
|
||||
- "${SERVER_PORT:-8080}:80" # 使用不同端口避免冲突
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- SPRING_PROFILES_ACTIVE=test # 使用测试 profile
|
||||
- DB_URL=${DB_URL:-jdbc:mysql://mysql-test:3306/polyhermes_test?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
|
||||
- DB_USERNAME=${DB_USERNAME:-root}
|
||||
- DB_PASSWORD=${DB_PASSWORD:-}
|
||||
- SERVER_PORT=8000
|
||||
- JWT_SECRET=${JWT_SECRET:-test-jwt-secret-key-for-testing-only}
|
||||
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-test-reset-key-for-testing-only}
|
||||
- LOG_LEVEL_ROOT=DEBUG
|
||||
- LOG_LEVEL_APP=DEBUG
|
||||
# 【测试环境】允许检测 pre-release 版本
|
||||
- ALLOW_PRERELEASE=true
|
||||
- GITHUB_REPO=${GITHUB_REPO:-WrBug/PolyHermes}
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
depends_on:
|
||||
mysql-test:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- polyhermes-test-network
|
||||
|
||||
mysql-test:
|
||||
image: mysql:8.2
|
||||
container_name: polyhermes-mysql-test
|
||||
ports:
|
||||
- "${MYSQL_PORT:-3308}:3306" # 使用不同端口
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-testpassword}
|
||||
- MYSQL_DATABASE=polyhermes_test
|
||||
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
||||
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- mysql-test-data:/var/lib/mysql
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-testpassword}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- polyhermes-test-network
|
||||
|
||||
volumes:
|
||||
mysql-test-data:
|
||||
|
||||
networks:
|
||||
polyhermes-test-network:
|
||||
driver: bridge
|
||||
@@ -32,6 +32,9 @@ services:
|
||||
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
|
||||
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
|
||||
# 动态更新配置
|
||||
- ALLOW_PRERELEASE=${ALLOW_PRERELEASE:-false}
|
||||
- GITHUB_REPO=${GITHUB_REPO:-WrBug/PolyHermes}
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
depends_on:
|
||||
|
||||
@@ -54,6 +54,25 @@ http {
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# 【新增】更新服务 API(直接代理到 Python)
|
||||
location /api/update/ {
|
||||
# 代理到更新服务(端口 9090)
|
||||
proxy_pass http://localhost:9090/;
|
||||
|
||||
# 传递请求头
|
||||
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 Authorization $http_authorization;
|
||||
|
||||
# 超时设置(更新操作可能需要较长时间)
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
# WebSocket 代理
|
||||
location /ws {
|
||||
proxy_pass http://backend;
|
||||
|
||||
+33
-11
@@ -1,9 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 启动脚本:同时启动 Nginx 和后端服务
|
||||
# 启动脚本:启动更新服务、后端服务和 Nginx
|
||||
|
||||
set -e
|
||||
|
||||
echo "========================================="
|
||||
echo " PolyHermes 容器启动"
|
||||
echo "========================================="
|
||||
|
||||
# 默认值常量
|
||||
DEFAULT_JWT_SECRET="change-me-in-production"
|
||||
DEFAULT_ADMIN_RESET_KEY="change-me-in-production"
|
||||
@@ -42,6 +46,9 @@ check_security_config
|
||||
# 函数:清理进程
|
||||
cleanup() {
|
||||
echo "收到退出信号,清理进程..."
|
||||
if [ -n "$UPDATE_SERVICE_PID" ]; then
|
||||
kill $UPDATE_SERVICE_PID 2>/dev/null || true
|
||||
fi
|
||||
if [ -n "$BACKEND_PID" ]; then
|
||||
kill $BACKEND_PID 2>/dev/null || true
|
||||
fi
|
||||
@@ -52,27 +59,42 @@ cleanup() {
|
||||
# 注册信号处理
|
||||
trap cleanup SIGTERM SIGINT
|
||||
|
||||
# 启动后端服务(以 appuser 用户运行,后台运行)
|
||||
echo "启动后端服务..."
|
||||
# 自动使用系统时区
|
||||
# 1. 启动更新服务(后台运行,端口 9090)
|
||||
echo "🚀 启动更新服务..."
|
||||
python3 /app/update-service.py &
|
||||
UPDATE_SERVICE_PID=$!
|
||||
echo "✅ 更新服务已启动 (PID: $UPDATE_SERVICE_PID, Port: 9090)"
|
||||
|
||||
# 等待更新服务就绪
|
||||
sleep 2
|
||||
|
||||
# 2. 启动后端服务(后台运行,端口 8000)
|
||||
echo "🚀 启动后端服务..."
|
||||
java -jar /app/app.jar --spring.profiles.active=${SPRING_PROFILES_ACTIVE:-prod} &
|
||||
BACKEND_PID=$!
|
||||
echo "✅ 后端服务已启动 (PID: $BACKEND_PID, Port: 8000)"
|
||||
|
||||
# 等待后端服务启动
|
||||
echo "等待后端服务启动..."
|
||||
# 3. 等待后端服务启动
|
||||
echo "⏳ 等待后端服务就绪..."
|
||||
for i in {1..60}; do
|
||||
if curl -f http://localhost:8000/api/system/health > /dev/null 2>&1; then
|
||||
echo "后端服务已启动"
|
||||
echo "✅ 后端服务健康检查通过"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 60 ]; then
|
||||
echo "后端服务启动超时"
|
||||
echo "❌ 后端服务启动超时"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# 启动 Nginx(前台运行,作为主进程)
|
||||
echo "启动 Nginx..."
|
||||
exec nginx -g "daemon off;"
|
||||
# 4. 启动 Nginx(前台运行,保持容器存活)
|
||||
echo "🚀 启动 Nginx..."
|
||||
echo "========================================="
|
||||
echo " 容器启动完成"
|
||||
echo " - 更新服务: http://localhost:9090"
|
||||
echo " - 后端服务: http://localhost:8000"
|
||||
echo " - 前端服务: http://localhost:80"
|
||||
echo "========================================="
|
||||
|
||||
exec nginx -g "daemon off;"
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PolyHermes 动态更新服务
|
||||
负责检查更新、下载更新包、执行更新和回滚
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
import shutil
|
||||
import tarfile
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from flask import Flask, jsonify, request
|
||||
from datetime import datetime
|
||||
|
||||
# ==================== 配置 ====================
|
||||
app = Flask(__name__)
|
||||
|
||||
# 日志配置
|
||||
LOG_FILE = Path('/var/log/polyhermes/update-service.log')
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(LOG_FILE),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 路径配置
|
||||
APP_DIR = Path('/app')
|
||||
VERSION_FILE = APP_DIR / 'version.json'
|
||||
UPDATES_DIR = APP_DIR / 'updates'
|
||||
BACKUPS_DIR = APP_DIR / 'backups'
|
||||
BACKEND_JAR = APP_DIR / 'app.jar'
|
||||
FRONTEND_DIR = Path('/usr/share/nginx/html')
|
||||
|
||||
# 创建必要目录
|
||||
UPDATES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# GitHub 配置
|
||||
GITHUB_REPO = os.getenv('GITHUB_REPO', 'WrBug/PolyHermes')
|
||||
ALLOW_PRERELEASE = os.getenv('ALLOW_PRERELEASE', 'false').lower() == 'true'
|
||||
BACKEND_URL = 'http://localhost:8000'
|
||||
|
||||
# 更新状态
|
||||
update_status = {
|
||||
'updating': False,
|
||||
'progress': 0,
|
||||
'message': '就绪',
|
||||
'error': None
|
||||
}
|
||||
|
||||
# ==================== 工具函数 ====================
|
||||
|
||||
def get_current_version():
|
||||
"""获取当前版本"""
|
||||
try:
|
||||
if VERSION_FILE.exists():
|
||||
with open(VERSION_FILE) as f:
|
||||
data = json.load(f)
|
||||
return data.get('version', 'unknown')
|
||||
return 'unknown'
|
||||
except Exception as e:
|
||||
logger.error(f"读取版本失败: {e}")
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def fetch_latest_release():
|
||||
"""获取最新 Release"""
|
||||
try:
|
||||
if ALLOW_PRERELEASE:
|
||||
# 测试模式:获取所有 Release(包括 pre-release)
|
||||
url = f'https://api.github.com/repos/{GITHUB_REPO}/releases'
|
||||
response = requests.get(url, headers={'Accept': 'application/vnd.github.v3+json'}, timeout=10)
|
||||
releases = response.json()
|
||||
|
||||
if releases and len(releases) > 0:
|
||||
latest = releases[0]
|
||||
logger.info(f"检测到版本: {latest['tag_name']} (pre-release: {latest.get('prerelease', False)})")
|
||||
return {
|
||||
'tag': latest['tag_name'],
|
||||
'name': latest['name'],
|
||||
'body': latest['body'],
|
||||
'published_at': latest['published_at'],
|
||||
'assets': latest['assets'],
|
||||
'prerelease': latest.get('prerelease', False)
|
||||
}
|
||||
else:
|
||||
# 生产模式:只获取正式版本
|
||||
url = f'https://api.github.com/repos/{GITHUB_REPO}/releases/latest'
|
||||
response = requests.get(url, headers={'Accept': 'application/vnd.github.v3+json'}, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {
|
||||
'tag': data['tag_name'],
|
||||
'name': data['name'],
|
||||
'body': data['body'],
|
||||
'published_at': data['published_at'],
|
||||
'assets': data['assets'],
|
||||
'prerelease': False
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取 Release 失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def compare_versions(v1, v2):
|
||||
"""
|
||||
比较两个版本号(语义化版本)
|
||||
返回: 1 if v1 > v2, -1 if v1 < v2, 0 if equal
|
||||
"""
|
||||
def normalize(v):
|
||||
parts = v.replace('v', '').split('-')[0].split('.')
|
||||
return [int(x) for x in parts]
|
||||
|
||||
try:
|
||||
parts1 = normalize(v1)
|
||||
parts2 = normalize(v2)
|
||||
|
||||
for i in range(max(len(parts1), len(parts2))):
|
||||
p1 = parts1[i] if i < len(parts1) else 0
|
||||
p2 = parts2[i] if i < len(parts2) else 0
|
||||
if p1 > p2:
|
||||
return 1
|
||||
elif p1 < p2:
|
||||
return -1
|
||||
return 0
|
||||
except:
|
||||
return 0
|
||||
|
||||
|
||||
def check_admin_permission(req):
|
||||
"""检查管理员权限"""
|
||||
auth_header = req.headers.get('Authorization')
|
||||
if not auth_header:
|
||||
return False
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f'{BACKEND_URL}/api/auth/verify',
|
||||
headers={'Authorization': auth_header},
|
||||
timeout=3
|
||||
)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(f"权限验证失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def download_file(url, dest_path):
|
||||
"""下载文件"""
|
||||
logger.info(f"开始下载: {url}")
|
||||
response = requests.get(url, stream=True, timeout=300)
|
||||
response.raise_for_status()
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded = 0
|
||||
|
||||
with open(dest_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if total_size > 0:
|
||||
progress = int((downloaded / total_size) * 30) # 下载占30%
|
||||
update_status['progress'] = progress
|
||||
|
||||
logger.info(f"下载完成: {dest_path}")
|
||||
return dest_path
|
||||
|
||||
|
||||
def backup_current_version():
|
||||
"""备份当前版本"""
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_dir = BACKUPS_DIR / timestamp
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(f"创建备份: {backup_dir}")
|
||||
|
||||
# 备份后端 JAR
|
||||
if BACKEND_JAR.exists():
|
||||
shutil.copy2(BACKEND_JAR, backup_dir / 'app.jar')
|
||||
|
||||
# 备份前端(打包)
|
||||
if FRONTEND_DIR.exists():
|
||||
frontend_backup = backup_dir / 'frontend.tar.gz'
|
||||
with tarfile.open(frontend_backup, 'w:gz') as tar:
|
||||
tar.add(FRONTEND_DIR, arcname='.')
|
||||
|
||||
# 备份版本信息
|
||||
if VERSION_FILE.exists():
|
||||
shutil.copy2(VERSION_FILE, backup_dir / 'version.json')
|
||||
|
||||
logger.info(f"备份完成: {backup_dir}")
|
||||
return backup_dir
|
||||
|
||||
|
||||
def restore_backup(backup_dir):
|
||||
"""恢复备份"""
|
||||
logger.info(f"开始恢复备份: {backup_dir}")
|
||||
|
||||
# 恢复后端 JAR
|
||||
backup_jar = backup_dir / 'app.jar'
|
||||
if backup_jar.exists():
|
||||
shutil.copy2(backup_jar, BACKEND_JAR)
|
||||
|
||||
# 恢复前端
|
||||
frontend_backup = backup_dir / 'frontend.tar.gz'
|
||||
if frontend_backup.exists():
|
||||
# 清空前端目录
|
||||
if FRONTEND_DIR.exists():
|
||||
shutil.rmtree(FRONTEND_DIR)
|
||||
FRONTEND_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 解压备份
|
||||
with tarfile.open(frontend_backup, 'r:gz') as tar:
|
||||
tar.extractall(FRONTEND_DIR)
|
||||
|
||||
# 恢复版本信息
|
||||
backup_version = backup_dir / 'version.json'
|
||||
if backup_version.exists():
|
||||
shutil.copy2(backup_version, VERSION_FILE)
|
||||
|
||||
logger.info("备份恢复完成")
|
||||
|
||||
|
||||
def perform_update(target_version):
|
||||
"""执行更新流程"""
|
||||
global update_status
|
||||
|
||||
try:
|
||||
update_status['updating'] = True
|
||||
update_status['progress'] = 0
|
||||
update_status['message'] = '开始更新...'
|
||||
update_status['error'] = None
|
||||
|
||||
# 1. 获取最新 Release
|
||||
update_status['message'] = '获取 Release 信息...'
|
||||
release = fetch_latest_release()
|
||||
if not release:
|
||||
raise Exception("无法获取 Release 信息")
|
||||
|
||||
tag = release['tag']
|
||||
assets = release['assets']
|
||||
|
||||
# 查找更新包
|
||||
update_asset = None
|
||||
for asset in assets:
|
||||
if asset['name'].endswith('-update.tar.gz'):
|
||||
update_asset = asset
|
||||
break
|
||||
|
||||
if not update_asset:
|
||||
raise Exception(f"未找到更新包: {tag}")
|
||||
|
||||
update_status['progress'] = 10
|
||||
|
||||
# 2. 下载更新包
|
||||
update_status['message'] = f'下载更新包 {tag}...'
|
||||
download_url = update_asset['browser_download_url']
|
||||
download_path = UPDATES_DIR / update_asset['name']
|
||||
download_file(download_url, download_path)
|
||||
|
||||
update_status['progress'] = 40
|
||||
|
||||
# 3. 备份当前版本
|
||||
update_status['message'] = '备份当前版本...'
|
||||
backup_dir = backup_current_version()
|
||||
|
||||
update_status['progress'] = 50
|
||||
|
||||
# 4. 解压更新包
|
||||
update_status['message'] = '解压更新包...'
|
||||
extract_dir = UPDATES_DIR / 'current'
|
||||
if extract_dir.exists():
|
||||
shutil.rmtree(extract_dir)
|
||||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tarfile.open(download_path, 'r:gz') as tar:
|
||||
tar.extractall(extract_dir)
|
||||
|
||||
update_status['progress'] = 60
|
||||
|
||||
# 5. 停止后端进程
|
||||
update_status['message'] = '停止后端服务...'
|
||||
logger.info("停止后端进程...")
|
||||
subprocess.run(['pkill', '-f', 'java -jar'], check=False)
|
||||
time.sleep(2)
|
||||
|
||||
update_status['progress'] = 65
|
||||
|
||||
# 6. 替换文件
|
||||
update_status['message'] = '更新文件...'
|
||||
|
||||
# 替换后端 JAR
|
||||
new_jar = extract_dir / 'backend' / 'polyhermes.jar'
|
||||
if new_jar.exists():
|
||||
shutil.copy2(new_jar, BACKEND_JAR)
|
||||
logger.info("后端 JAR 已更新")
|
||||
|
||||
# 替换前端文件
|
||||
new_frontend = extract_dir / 'frontend'
|
||||
if new_frontend.exists():
|
||||
if FRONTEND_DIR.exists():
|
||||
shutil.rmtree(FRONTEND_DIR)
|
||||
shutil.copytree(new_frontend, FRONTEND_DIR)
|
||||
logger.info("前端文件已更新")
|
||||
|
||||
# 更新版本信息
|
||||
new_version = extract_dir / 'version.json'
|
||||
if new_version.exists():
|
||||
shutil.copy2(new_version, VERSION_FILE)
|
||||
logger.info("版本信息已更新")
|
||||
|
||||
update_status['progress'] = 75
|
||||
|
||||
# 7. 重启后端服务
|
||||
update_status['message'] = '重启后端服务...'
|
||||
logger.info("重启后端服务...")
|
||||
|
||||
# 创建后端日志文件
|
||||
backend_log_file = LOG_FILE.parent / 'backend-update.log'
|
||||
backend_log = open(backend_log_file, 'w')
|
||||
|
||||
backend_process = subprocess.Popen([
|
||||
'java', '-jar', str(BACKEND_JAR),
|
||||
'--spring.profiles.active=prod'
|
||||
], stdout=backend_log, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
|
||||
logger.info(f"后端进程已启动 (PID: {backend_process.pid})")
|
||||
|
||||
update_status['progress'] = 80
|
||||
|
||||
# 8. 重载 Nginx
|
||||
update_status['message'] = '重载 Nginx...'
|
||||
subprocess.run(['nginx', '-s', 'reload'], check=True)
|
||||
|
||||
update_status['progress'] = 85
|
||||
|
||||
# 9. 健康检查
|
||||
update_status['message'] = '健康检查...'
|
||||
logger.info("等待后端服务启动...")
|
||||
|
||||
healthy = False
|
||||
max_wait_time = 90 # 增加到90秒,给后端更多启动时间
|
||||
last_process_check = 0
|
||||
|
||||
for i in range(max_wait_time):
|
||||
# 每5秒检查一次进程状态
|
||||
if i - last_process_check >= 5:
|
||||
last_process_check = i
|
||||
if backend_process.poll() is not None:
|
||||
# 进程已退出
|
||||
backend_log.close()
|
||||
error_msg = ''
|
||||
try:
|
||||
with open(backend_log_file, 'r') as f:
|
||||
lines = f.readlines()
|
||||
error_msg = ''.join(lines[-50:]) # 读取最后50行
|
||||
except:
|
||||
pass
|
||||
|
||||
logger.error(f"后端进程异常退出(等待了 {i} 秒),退出码: {backend_process.returncode}")
|
||||
if error_msg:
|
||||
logger.error(f"后端日志最后50行:\n{error_msg}")
|
||||
raise Exception(f"后端服务启动失败,退出码: {backend_process.returncode}")
|
||||
else:
|
||||
logger.debug(f"后端进程仍在运行 (PID: {backend_process.pid})")
|
||||
|
||||
# 尝试健康检查
|
||||
try:
|
||||
response = requests.get(f'{BACKEND_URL}/api/system/health', timeout=2)
|
||||
if response.status_code == 200:
|
||||
healthy = True
|
||||
backend_log.close()
|
||||
logger.info(f"健康检查通过(等待了 {i+1} 秒)")
|
||||
break
|
||||
except requests.exceptions.ConnectionError:
|
||||
# 连接被拒绝,说明后端还没启动或端口未监听
|
||||
if i % 10 == 0 and i > 0: # 每10秒记录一次
|
||||
logger.debug(f"健康检查尝试 {i+1}/{max_wait_time}: 连接被拒绝(后端可能还在启动中)")
|
||||
except requests.exceptions.Timeout:
|
||||
# 超时
|
||||
if i % 10 == 0: # 每10秒记录一次
|
||||
logger.debug(f"健康检查尝试 {i+1}/{max_wait_time}: 请求超时")
|
||||
except Exception as e:
|
||||
logger.warning(f"健康检查异常: {e}")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
if not healthy:
|
||||
# 关闭日志文件并尝试读取错误信息
|
||||
backend_log.close()
|
||||
error_msg = ''
|
||||
try:
|
||||
with open(backend_log_file, 'r') as f:
|
||||
lines = f.readlines()
|
||||
error_msg = ''.join(lines[-100:]) # 读取最后100行
|
||||
except:
|
||||
pass
|
||||
|
||||
# 检查进程状态
|
||||
process_status = backend_process.poll()
|
||||
if process_status is None:
|
||||
# 进程还在运行,但健康检查失败
|
||||
logger.error(f"健康检查失败:后端进程仍在运行 (PID: {backend_process.pid}),但无法访问健康检查端点")
|
||||
logger.error("可能的原因:端口未监听、健康检查端点异常、或启动时间过长")
|
||||
else:
|
||||
# 进程已退出
|
||||
logger.error(f"健康检查失败:后端进程已退出,退出码: {process_status}")
|
||||
|
||||
if error_msg:
|
||||
logger.error(f"后端启动日志(最后100行):\n{error_msg}")
|
||||
|
||||
logger.error("健康检查失败,开始回滚...")
|
||||
update_status['message'] = '健康检查失败,回滚中...'
|
||||
|
||||
# 确保后端进程已停止
|
||||
try:
|
||||
backend_process.terminate()
|
||||
backend_process.wait(timeout=5)
|
||||
except:
|
||||
subprocess.run(['pkill', '-9', '-f', 'java.*app.jar'], check=False)
|
||||
|
||||
restore_backup(backup_dir)
|
||||
|
||||
# 等待一下再重启
|
||||
time.sleep(2)
|
||||
|
||||
# 重启后端(使用旧版本)
|
||||
logger.info("重启旧版本后端服务...")
|
||||
subprocess.Popen([
|
||||
'java', '-jar', str(BACKEND_JAR),
|
||||
'--spring.profiles.active=prod'
|
||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
|
||||
subprocess.run(['nginx', '-s', 'reload'], check=True)
|
||||
|
||||
raise Exception(f"健康检查失败(等待了 {max_wait_time} 秒),已回滚到旧版本。请查看日志文件 {backend_log_file} 了解详情")
|
||||
|
||||
update_status['progress'] = 100
|
||||
update_status['message'] = f'更新成功:{tag}'
|
||||
logger.info(f"更新成功:{tag}")
|
||||
|
||||
# 清理临时文件
|
||||
if download_path.exists():
|
||||
download_path.unlink()
|
||||
if extract_dir.exists():
|
||||
shutil.rmtree(extract_dir)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新失败: {e}")
|
||||
update_status['error'] = str(e)
|
||||
update_status['message'] = f'更新失败: {str(e)}'
|
||||
finally:
|
||||
update_status['updating'] = False
|
||||
|
||||
|
||||
# ==================== API 路由 ====================
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health():
|
||||
"""健康检查"""
|
||||
return jsonify({'code': 0, 'data': 'ok', 'message': 'success'})
|
||||
|
||||
|
||||
@app.route('/version', methods=['GET'])
|
||||
def version():
|
||||
"""获取当前版本"""
|
||||
try:
|
||||
if VERSION_FILE.exists():
|
||||
with open(VERSION_FILE) as f:
|
||||
data = json.load(f)
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'version': data.get('version', 'unknown'),
|
||||
'tag': data.get('tag', 'unknown'),
|
||||
'buildTime': data.get('buildTime', '')
|
||||
},
|
||||
'message': 'success'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'version': 'unknown',
|
||||
'tag': 'unknown',
|
||||
'buildTime': ''
|
||||
},
|
||||
'message': 'success'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"获取版本失败: {e}")
|
||||
return jsonify({
|
||||
'code': 500,
|
||||
'data': None,
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/check', methods=['GET'])
|
||||
def check():
|
||||
"""检查更新"""
|
||||
try:
|
||||
current_version = get_current_version()
|
||||
release = fetch_latest_release()
|
||||
|
||||
if not release:
|
||||
return jsonify({
|
||||
'code': 500,
|
||||
'data': None,
|
||||
'message': '无法获取 Release 信息'
|
||||
}), 500
|
||||
|
||||
latest_tag = release['tag']
|
||||
latest_version = latest_tag.lstrip('v')
|
||||
|
||||
has_update = compare_versions(latest_version, current_version) > 0
|
||||
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'hasUpdate': has_update,
|
||||
'currentVersion': current_version,
|
||||
'latestVersion': latest_version,
|
||||
'latestTag': latest_tag,
|
||||
'releaseNotes': release.get('body', ''),
|
||||
'publishedAt': release.get('published_at', ''),
|
||||
'prerelease': release.get('prerelease', False)
|
||||
},
|
||||
'message': 'success'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"检查更新失败: {e}")
|
||||
return jsonify({
|
||||
'code': 500,
|
||||
'data': None,
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/update', methods=['POST'])
|
||||
def update():
|
||||
"""执行更新(需要管理员权限)"""
|
||||
|
||||
# 权限检查
|
||||
if not check_admin_permission(request):
|
||||
return jsonify({
|
||||
'code': 403,
|
||||
'data': None,
|
||||
'message': '需要管理员权限'
|
||||
}), 403
|
||||
|
||||
if update_status['updating']:
|
||||
return jsonify({
|
||||
'code': 409,
|
||||
'data': None,
|
||||
'message': '正在更新中,请稍后'
|
||||
}), 409
|
||||
|
||||
# 异步执行更新
|
||||
thread = Thread(target=perform_update, args=('latest',))
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': '更新已启动',
|
||||
'message': 'success'
|
||||
})
|
||||
|
||||
|
||||
@app.route('/status', methods=['GET'])
|
||||
def status():
|
||||
"""获取更新状态"""
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': {
|
||||
'updating': update_status['updating'],
|
||||
'progress': update_status['progress'],
|
||||
'message': update_status['message'],
|
||||
'error': update_status['error']
|
||||
},
|
||||
'message': 'success'
|
||||
})
|
||||
|
||||
|
||||
@app.route('/logs', methods=['GET'])
|
||||
def logs():
|
||||
"""获取更新日志(需要管理员权限)"""
|
||||
|
||||
# 权限检查
|
||||
if not check_admin_permission(request):
|
||||
return jsonify({
|
||||
'code': 403,
|
||||
'data': None,
|
||||
'message': '需要管理员权限'
|
||||
}), 403
|
||||
|
||||
try:
|
||||
if LOG_FILE.exists():
|
||||
with open(LOG_FILE) as f:
|
||||
lines = f.readlines()
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': ''.join(lines[-1000:]), # 最后1000行
|
||||
'message': 'success'
|
||||
})
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'data': '',
|
||||
'message': 'success'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"获取日志失败: {e}")
|
||||
return jsonify({
|
||||
'code': 500,
|
||||
'data': None,
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# ==================== 主程序 ====================
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.info("=" * 50)
|
||||
logger.info("PolyHermes 更新服务启动")
|
||||
logger.info(f"GitHub 仓库: {GITHUB_REPO}")
|
||||
logger.info(f"允许 Pre-release: {ALLOW_PRERELEASE}")
|
||||
logger.info(f"当前版本: {get_current_version()}")
|
||||
logger.info("=" * 50)
|
||||
|
||||
# 启动 Flask 服务
|
||||
app.run(host='0.0.0.0', port=9090, debug=False)
|
||||
@@ -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,324 @@
|
||||
# Docker 版本号确定流程
|
||||
|
||||
## 概述
|
||||
|
||||
Docker 镜像的版本号从 **GitHub Release Tag** 获取,通过 GitHub Actions 自动传递到 Dockerfile,最终存储在容器内的 `/app/version.json` 文件中。
|
||||
|
||||
## 完整流程
|
||||
|
||||
```
|
||||
1. GitHub Release Tag (v1.0.0)
|
||||
↓
|
||||
2. GitHub Actions 触发
|
||||
↓
|
||||
3. 从 Tag 提取版本号
|
||||
↓
|
||||
4. 作为 build-args 传递给 Dockerfile
|
||||
↓
|
||||
5. Dockerfile 写入 /app/version.json
|
||||
↓
|
||||
6. 容器运行时读取版本号
|
||||
```
|
||||
|
||||
## 详细步骤
|
||||
|
||||
### 步骤 1: 创建 GitHub Release
|
||||
|
||||
通过 GitHub Releases 页面或 `create-release.sh` 脚本创建 Release:
|
||||
|
||||
```bash
|
||||
# 示例:创建 v1.0.1 版本
|
||||
./create-release.sh -t v1.0.1 -T "Release v1.0.1" -d "更新内容"
|
||||
```
|
||||
|
||||
**结果**:
|
||||
- 创建 Git tag: `v1.0.1`
|
||||
- 创建 GitHub Release: `v1.0.1`
|
||||
- 触发 GitHub Actions workflow
|
||||
|
||||
### 步骤 2: GitHub Actions 触发
|
||||
|
||||
GitHub Actions 监听 `release: published` 事件:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/docker-build.yml
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published # 当创建 release 时触发
|
||||
```
|
||||
|
||||
**事件数据**:
|
||||
- `github.event.release.tag_name`: `"v1.0.1"`
|
||||
- `github.event.release.prerelease`: `false` 或 `true`
|
||||
|
||||
### 步骤 3: 提取版本号
|
||||
|
||||
GitHub Actions 从 Tag 中提取版本号:
|
||||
|
||||
```bash
|
||||
# .github/workflows/docker-build.yml (步骤: Extract version)
|
||||
TAG_NAME="${{ github.event.release.tag_name }}" # "v1.0.1"
|
||||
VERSION=${TAG_NAME#v} # "1.0.1" (移除 v 前缀)
|
||||
```
|
||||
|
||||
**提取结果**:
|
||||
- `VERSION`: `"1.0.1"` (纯版本号,无 v 前缀)
|
||||
- `TAG`: `"v1.0.1"` (完整 tag,带 v 前缀)
|
||||
- `IS_PRERELEASE`: `false` 或 `true`
|
||||
|
||||
**版本号格式验证**:
|
||||
- ✅ 正确:`v1.0.0`, `v2.10.102`, `v1.0.0-beta`
|
||||
- ❌ 错误:`v1.0`, `1.0.0`, `v1.0.0.1`
|
||||
|
||||
### 步骤 4: 传递构建参数
|
||||
|
||||
版本号作为 Docker build-args 传递给 Dockerfile:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/docker-build.yml
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
build-args: |
|
||||
BUILD_IN_DOCKER=false
|
||||
VERSION=${{ steps.extract_version.outputs.VERSION }} # "1.0.1"
|
||||
GIT_TAG=${{ steps.extract_version.outputs.TAG }} # "v1.0.1"
|
||||
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
||||
```
|
||||
|
||||
### 步骤 5: Dockerfile 接收参数
|
||||
|
||||
Dockerfile 使用 ARG 接收构建参数:
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile (第 92-94 行)
|
||||
ARG VERSION=dev # 默认值: dev
|
||||
ARG GIT_TAG=dev # 默认值: dev
|
||||
|
||||
# 写入 version.json
|
||||
RUN echo "{\"version\":\"${VERSION}\",\"tag\":\"${GIT_TAG}\",\"buildTime\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > /app/version.json
|
||||
```
|
||||
|
||||
**生成的文件内容** (`/app/version.json`):
|
||||
```json
|
||||
{
|
||||
"version": "1.0.1",
|
||||
"tag": "v1.0.1",
|
||||
"buildTime": "2026-01-20T15:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 6: 容器运行时读取
|
||||
|
||||
更新服务通过 `/api/update/version` 接口读取版本号:
|
||||
|
||||
```python
|
||||
# docker/update-service.py
|
||||
def get_current_version():
|
||||
"""获取当前版本"""
|
||||
if VERSION_FILE.exists():
|
||||
with open(VERSION_FILE) as f:
|
||||
data = json.load(f)
|
||||
return data.get('version', 'unknown') # 返回: "1.0.1"
|
||||
```
|
||||
|
||||
前端通过 API 获取并显示:
|
||||
|
||||
```typescript
|
||||
// frontend/src/pages/SystemUpdate.tsx
|
||||
const response = await apiClient.get('/update/version')
|
||||
const { version } = response.data.data // "1.0.1"
|
||||
```
|
||||
|
||||
## 不同场景下的版本号
|
||||
|
||||
### 场景 1: GitHub Actions 自动构建(正式发布)
|
||||
|
||||
**输入**:
|
||||
- Release Tag: `v1.0.1`
|
||||
- Release Type: Published (正式版本)
|
||||
|
||||
**流程**:
|
||||
1. GitHub Actions 提取: `VERSION="1.0.1"`, `GIT_TAG="v1.0.1"`
|
||||
2. 传递给 Dockerfile
|
||||
3. 生成 `/app/version.json`: `{"version": "1.0.1", "tag": "v1.0.1", ...}`
|
||||
|
||||
**Docker 镜像标签**:
|
||||
- `wrbug/polyhermes:v1.0.1` ✅
|
||||
- `wrbug/polyhermes:latest` ✅ (因为不是 pre-release)
|
||||
|
||||
### 场景 2: Pre-release(测试版本)
|
||||
|
||||
**输入**:
|
||||
- Release Tag: `v1.0.1-beta`
|
||||
- Release Type: Pre-release
|
||||
|
||||
**流程**:
|
||||
1. GitHub Actions 提取: `VERSION="1.0.1-beta"`, `GIT_TAG="v1.0.1-beta"`
|
||||
2. 传递给 Dockerfile
|
||||
3. 生成 `/app/version.json`: `{"version": "1.0.1-beta", "tag": "v1.0.1-beta", ...}`
|
||||
|
||||
**Docker 镜像标签**:
|
||||
- `wrbug/polyhermes:v1.0.1-beta` ✅
|
||||
- `wrbug/polyhermes:latest` ❌ (pre-release 不推送到 latest)
|
||||
|
||||
### 场景 3: 本地构建(开发环境)
|
||||
|
||||
**命令行**:
|
||||
```bash
|
||||
docker build -t polyhermes:local .
|
||||
```
|
||||
|
||||
**流程**:
|
||||
1. 没有传递 `VERSION` 和 `GIT_TAG` 参数
|
||||
2. Dockerfile 使用默认值: `VERSION=dev`, `GIT_TAG=dev`
|
||||
3. 生成 `/app/version.json`: `{"version": "dev", "tag": "dev", ...}`
|
||||
|
||||
**显式指定版本号**:
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg VERSION=1.0.1 \
|
||||
--build-arg GIT_TAG=v1.0.1 \
|
||||
-t polyhermes:local .
|
||||
```
|
||||
|
||||
### 场景 4: 本地 Docker Compose
|
||||
|
||||
**docker-compose.yml**:
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VERSION: 1.0.1
|
||||
GIT_TAG: v1.0.1
|
||||
```
|
||||
|
||||
## 版本号存储位置
|
||||
|
||||
### 容器内路径
|
||||
|
||||
```
|
||||
/app/version.json
|
||||
```
|
||||
|
||||
### 文件格式
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.1", // 纯版本号(无 v 前缀)
|
||||
"tag": "v1.0.1", // 完整 tag(带 v 前缀)
|
||||
"buildTime": "2026-01-20T15:30:00Z" // 构建时间(UTC)
|
||||
}
|
||||
```
|
||||
|
||||
### 访问方式
|
||||
|
||||
**1. 通过 API**:
|
||||
```bash
|
||||
curl http://localhost/api/update/version
|
||||
```
|
||||
|
||||
**2. 进入容器查看**:
|
||||
```bash
|
||||
docker exec -it <container_id> cat /app/version.json
|
||||
```
|
||||
|
||||
**3. 前端显示**:
|
||||
- 系统设置 → 系统更新页面
|
||||
- 显示当前版本: `v1.0.1`
|
||||
|
||||
## 版本号的作用
|
||||
|
||||
### 1. 显示当前版本
|
||||
|
||||
前端和系统更新页面显示当前运行的版本号。
|
||||
|
||||
### 2. 检查更新
|
||||
|
||||
更新服务通过比较当前版本和 GitHub 最新版本判断是否有更新:
|
||||
|
||||
```python
|
||||
# docker/update-service.py
|
||||
current_version = get_current_version() # "1.0.1"
|
||||
latest_version = fetch_latest_release() # "1.0.2"
|
||||
|
||||
if compare_versions(latest_version, current_version) > 0:
|
||||
# 有新版本,提示更新
|
||||
```
|
||||
|
||||
### 3. 版本追踪
|
||||
|
||||
记录 Docker 镜像的构建版本,便于追踪和回滚。
|
||||
|
||||
## 关键文件
|
||||
|
||||
| 文件 | 作用 | 版本号来源 |
|
||||
|------|------|-----------|
|
||||
| `.github/workflows/docker-build.yml` | GitHub Actions 工作流 | `github.event.release.tag_name` |
|
||||
| `Dockerfile` | Docker 构建配置 | 构建参数 `VERSION`, `GIT_TAG` |
|
||||
| `/app/version.json` | 版本号存储文件 | Dockerfile 生成 |
|
||||
| `docker/update-service.py` | 更新服务 | 读取 `/app/version.json` |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 为什么版本号是 `dev`?
|
||||
|
||||
**A**: 本地构建时没有传递版本号参数,使用了默认值。
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg VERSION=1.0.1 \
|
||||
--build-arg GIT_TAG=v1.0.1 \
|
||||
-t polyhermes:local .
|
||||
```
|
||||
|
||||
### Q2: 如何查看当前容器的版本号?
|
||||
|
||||
**A**:
|
||||
```bash
|
||||
# 方法1: API 接口
|
||||
curl http://localhost/api/update/version
|
||||
|
||||
# 方法2: 进入容器
|
||||
docker exec -it <container_id> cat /app/version.json
|
||||
|
||||
# 方法3: 前端页面
|
||||
系统设置 → 系统更新 → 查看"当前版本"
|
||||
```
|
||||
|
||||
### Q3: 版本号格式错误怎么办?
|
||||
|
||||
**A**: GitHub Actions 会验证版本号格式:
|
||||
- ✅ 正确:`v1.0.0`, `v1.0.0-beta`
|
||||
- ❌ 错误:`v1.0`, `1.0.0`
|
||||
|
||||
如果格式错误,构建会失败并提示错误信息。
|
||||
|
||||
### Q4: Pre-release 和正式版本的版本号有什么区别?
|
||||
|
||||
**A**:
|
||||
- **格式**: 都可以使用相同的格式(`v1.0.1-beta` vs `v1.0.1`)
|
||||
- **存储**: 都存储在 `/app/version.json` 中
|
||||
- **Docker 标签**: Pre-release 不会推送到 `latest` 标签
|
||||
- **通知**: Pre-release 不会发送 Telegram 通知
|
||||
|
||||
## 总结
|
||||
|
||||
Docker 版本号的确定流程:
|
||||
|
||||
1. **来源**: GitHub Release Tag
|
||||
2. **提取**: GitHub Actions 从 tag 中提取版本号
|
||||
3. **传递**: 通过 Docker build-args 传递
|
||||
4. **存储**: 写入容器内的 `/app/version.json`
|
||||
5. **使用**: 用于显示、检查更新、版本追踪
|
||||
|
||||
关键点:
|
||||
- ✅ 版本号来自 **GitHub Release Tag**
|
||||
- ✅ 格式必须符合:`v数字.数字.数字[-后缀]`
|
||||
- ✅ 默认值为 `dev`(本地构建时)
|
||||
- ✅ 支持 Pre-release 标记
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
||||
# 动态更新功能实现检查报告
|
||||
|
||||
基于 `docs/zh/DYNAMIC_UPDATE.md` 文档和现有代码,检查动态更新功能的实现情况。
|
||||
|
||||
## ✅ 已实现的功能
|
||||
|
||||
### 1. 后端更新服务
|
||||
- ✅ `docker/update-service.py` 已实现
|
||||
- 检查更新:`GET /check`
|
||||
- 执行更新:`POST /update`
|
||||
- 更新状态:`GET /status`
|
||||
- 更新日志:`GET /logs`
|
||||
- 获取版本:`GET /version`
|
||||
- 健康检查:`GET /health`
|
||||
- Pre-release 支持:通过 `ALLOW_PRERELEASE` 环境变量控制
|
||||
|
||||
### 2. Nginx 配置
|
||||
- ✅ `docker/nginx.conf` 已配置
|
||||
- `/api/update/` 路径代理到 `http://localhost:9090/`
|
||||
- 正确传递 Authorization 头
|
||||
- 超时设置合理(300秒)
|
||||
|
||||
### 3. Docker 启动脚本
|
||||
- ✅ `docker/start.sh` 已实现
|
||||
- 启动更新服务(端口 9090)
|
||||
- 启动后端服务(端口 8000)
|
||||
- 启动 Nginx(前台运行)
|
||||
- 正确的进程清理逻辑
|
||||
|
||||
### 4. Dockerfile
|
||||
- ✅ `Dockerfile` 已配置
|
||||
- 安装 Python 和 Flask
|
||||
- 复制更新服务脚本
|
||||
- 创建必要的目录
|
||||
- 支持混合编译方案(`BUILD_IN_DOCKER` 参数)
|
||||
|
||||
### 5. GitHub Actions
|
||||
- ✅ `.github/workflows/docker-build.yml` 已配置
|
||||
- 构建后端 JAR
|
||||
- 构建前端
|
||||
- 打包更新包
|
||||
- 计算校验和
|
||||
- 上传到 Release Assets
|
||||
- Pre-release 检测和过滤
|
||||
|
||||
### 6. 前端更新界面
|
||||
- ✅ `frontend/src/pages/SystemUpdate.tsx` 已实现
|
||||
- 显示当前版本
|
||||
- 检查更新
|
||||
- 显示更新信息
|
||||
- 执行更新
|
||||
- 更新进度显示
|
||||
- 错误处理
|
||||
|
||||
### 7. 权限验证端点
|
||||
- ✅ `/api/auth/verify` 端点已存在
|
||||
- 位置:`backend/src/main/kotlin/com/wrbug/polymarketbot/controller/auth/AuthController.kt`
|
||||
|
||||
## ⚠️ 发现的问题
|
||||
|
||||
### 问题 1: `/api/auth/verify` 接口逻辑错误
|
||||
|
||||
**位置**: `backend/src/main/kotlin/com/wrbug/polymarketbot/controller/auth/AuthController.kt:192-212`
|
||||
|
||||
**问题**:
|
||||
```kotlin
|
||||
// 检查是否为管理员
|
||||
val role = httpRequest.getAttribute("role") as? String
|
||||
if (role != "ADMIN") {
|
||||
return ResponseEntity.status(403).body(...)
|
||||
}
|
||||
```
|
||||
|
||||
**原因**:
|
||||
1. JWT 拦截器(`JwtAuthenticationInterceptor`)只设置了 `username` 到 request attributes,**没有设置 `role`**
|
||||
2. User 实体**没有 `role` 字段**,而是使用 `isDefault` 字段来判断是否为管理员(默认账户就是管理员)
|
||||
|
||||
**修复方案**:
|
||||
需要修改 `/api/auth/verify` 接口,检查用户是否为默认账户:
|
||||
|
||||
```kotlin
|
||||
@GetMapping("/verify")
|
||||
fun verify(httpRequest: HttpServletRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
val username = httpRequest.getAttribute("username") as? String
|
||||
if (username == null) {
|
||||
return ResponseEntity.status(401).body(ApiResponse.error(ErrorCode.AUTH_ERROR, "未认证", messageSource))
|
||||
}
|
||||
|
||||
// 检查是否为默认账户(管理员)
|
||||
val user = userRepository.findByUsername(username)
|
||||
if (user == null || !user.isDefault) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error(ErrorCode.AUTH_ERROR, "需要管理员权限", messageSource))
|
||||
}
|
||||
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
} catch (e: Exception) {
|
||||
logger.error("验证权限异常: ${e.message}", e)
|
||||
ResponseEntity.status(500).body(ApiResponse.error(ErrorCode.SERVER_ERROR, "验证失败", messageSource))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**需要的依赖**:
|
||||
- 在 `AuthController` 中注入 `UserRepository`
|
||||
|
||||
### 问题 2: 前端 SystemUpdate 组件未使用 apiClient
|
||||
|
||||
**位置**: `frontend/src/pages/SystemUpdate.tsx`
|
||||
|
||||
**问题**:
|
||||
组件使用了原生的 `fetch` API,而不是项目统一的 `apiClient`。虽然 `apiClient` 有拦截器自动添加 Authorization header,但原生 `fetch` 不会自动添加。
|
||||
|
||||
**当前代码**:
|
||||
```typescript
|
||||
const response = await fetch('/api/update/execute', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**修复方案**:
|
||||
有两种方案:
|
||||
|
||||
**方案 1(推荐)**: 使用 `apiClient`
|
||||
```typescript
|
||||
import { apiClient } from '../services/api'
|
||||
|
||||
const response = await apiClient.post('/update/execute', {})
|
||||
```
|
||||
|
||||
**方案 2**: 手动添加 Authorization header
|
||||
```typescript
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch('/api/update/execute', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- 当前如果用户已登录,token 在 localStorage 中,Nginx 会传递 Authorization 头
|
||||
- 但使用 `apiClient` 更统一,且可以处理 token 刷新等情况
|
||||
|
||||
## 📋 已修复的问题
|
||||
|
||||
### 后端 ✅
|
||||
1. ✅ 已修复 `AuthController.verify()` 方法
|
||||
- ✅ 移除了错误的 `role` 检查
|
||||
- ✅ 添加了 `UserRepository` 依赖注入
|
||||
- ✅ 正确检查用户是否为默认账户(`isDefault == true`)
|
||||
|
||||
### 前端 ✅
|
||||
2. ✅ 已修复 `SystemUpdate.tsx` 组件
|
||||
- ✅ 将所有 `fetch` 调用替换为 `apiClient`
|
||||
- ✅ 确保自动携带 Authorization header
|
||||
- ✅ 统一错误处理逻辑
|
||||
|
||||
## ✅ 其他检查项
|
||||
|
||||
### 更新服务功能完整性
|
||||
- ✅ 检查更新(无需权限)
|
||||
- ✅ 获取版本(无需权限)
|
||||
- ✅ 执行更新(需要管理员权限)
|
||||
- ✅ 获取日志(需要管理员权限)
|
||||
- ✅ 获取状态(无需权限)
|
||||
|
||||
### 更新流程完整性
|
||||
- ✅ 下载更新包
|
||||
- ✅ 备份当前版本
|
||||
- ✅ 替换文件
|
||||
- ✅ 重启后端
|
||||
- ✅ 健康检查
|
||||
- ✅ 自动回滚
|
||||
|
||||
### 文档完整性
|
||||
- ✅ 技术方案文档存在
|
||||
- ✅ 架构设计清晰
|
||||
- ✅ 使用流程说明完整
|
||||
|
||||
## 📝 总结
|
||||
|
||||
**整体实现度**: 100% ✅
|
||||
|
||||
**已修复的问题**:
|
||||
1. ✅ `/api/auth/verify` 接口已修复(现在正确检查默认账户而非 role)
|
||||
2. ✅ 前端组件已改用 `apiClient` 保持一致性
|
||||
|
||||
**功能状态**:
|
||||
- ✅ 所有核心功能已实现
|
||||
- ✅ 所有问题已修复
|
||||
- ✅ 代码质量良好,无 lint 错误
|
||||
|
||||
**下一步**:
|
||||
1. 进行集成测试,验证更新流程端到端是否正常工作
|
||||
2. 测试权限验证是否生效(非管理员用户应无法执行更新)
|
||||
|
||||
---
|
||||
|
||||
**检查日期**: 2026-01-20
|
||||
**最后更新**: 2026-01-20(已修复所有问题)
|
||||
**检查人**: AI Assistant
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# PolyHermes 动态更新功能实施完成
|
||||
|
||||
## ✅ 已完成的文件修改
|
||||
|
||||
### 1. Docker 相关
|
||||
- ✅ `Dockerfile` - 混合编译方案(BUILD_IN_DOCKER 参数)
|
||||
- ✅ `docker/update-service.py` - Python 更新服务
|
||||
- ✅ `docker/start.sh` - 启动脚本(启动3个进程)
|
||||
- ✅ `docker/nginx.conf` - Nginx 代理配置(/api/update/)
|
||||
- ✅ `docker-compose.yml` - 添加环境变量(ALLOW_PRERELEASE, GITHUB_REPO)
|
||||
- ✅ `docker-compose.test.yml` - 测试环境配置
|
||||
|
||||
### 2. GitHub Actions
|
||||
- ✅ `.github/workflows/docker-build.yml` - 完整更新
|
||||
- Pre-release 检测
|
||||
- 前后端编译
|
||||
- 更新包打包和上传
|
||||
- Docker 构建(BUILD_IN_DOCKER=false)
|
||||
- 条件化 Telegram 通知
|
||||
|
||||
### 3. 文档
|
||||
- ✅ `docs/zh/DYNAMIC_UPDATE.md` - 完整技术文档
|
||||
|
||||
## 📋 实施清单
|
||||
|
||||
| 文件 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| Dockerfile | ✅ 完成 | 混合编译方案 |
|
||||
| docker/update-service.py | ✅ 完成 | 更新服务(Flask) |
|
||||
| docker/start.sh | ✅ 完成 | 启动3个进程 |
|
||||
| docker/nginx.conf | ✅ 完成 | 代理配置 |
|
||||
| docker-compose.yml | ✅ 完成 | 环境变量 |
|
||||
| docker-compose.test.yml | ✅ 完成 | 测试环境 |
|
||||
| .github/workflows/docker-build.yml | ✅ 完成 | CI/CD 完整流程 |
|
||||
| docs/zh/DYNAMIC_UPDATE.md | ✅ 完成 | 技术文档 |
|
||||
|
||||
## 🚀 下一步
|
||||
|
||||
### 测试流程
|
||||
|
||||
1. **本地测试**
|
||||
```bash
|
||||
# 本地构建测试
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
2. **Pre-release 测试**
|
||||
```bash
|
||||
# 创建测试 tag
|
||||
git tag v1.3.0-beta
|
||||
git push origin v1.3.0-beta
|
||||
|
||||
# GitHub 创建 Pre-release
|
||||
# GitHub Actions 会自动:
|
||||
# - 构建更新包
|
||||
# - 上传到 Release
|
||||
# - 构建 Docker 镜像(仅 tag)
|
||||
# - 不发送 Telegram
|
||||
```
|
||||
|
||||
3. **生产发布**
|
||||
```bash
|
||||
# 创建正式 tag
|
||||
git tag v1.3.0
|
||||
git push origin v1.3.0
|
||||
|
||||
# GitHub 创建正式 Release
|
||||
# GitHub Actions 会自动:
|
||||
# - 构建更新包
|
||||
# - 上传到 Release
|
||||
# - 构建 Docker 镜像(tag + latest)
|
||||
# - 发送 Telegram 通知
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **首次发布需要包含前端代码**
|
||||
- 需要先创建一个包含前端 UI 的 PR
|
||||
- 实现 SystemUpdate 页面(React 组件)
|
||||
- 路由、菜单等集成
|
||||
|
||||
2. **健康检查端点**
|
||||
- 确保 `/api/system/health` 端点存在
|
||||
- 如果不存在,需要修改 `start.sh` 和 `Dockerfile` 中的健康检查URL
|
||||
|
||||
3. **权限验证端点**
|
||||
- 确保 `/api/auth/verify` 端点存在
|
||||
- 或修改 `update-service.py` 中的权限验证逻辑
|
||||
|
||||
## 📝 待办事项
|
||||
|
||||
- [ ] 创建前端 SystemUpdate 页面
|
||||
- [ ] 集成到系统设置菜单
|
||||
- [ ] 测试本地构建流程
|
||||
- [ ] 创建第一个 Pre-release 测试
|
||||
- [ ] 验证更新流程
|
||||
- [ ] 生产环境发布
|
||||
|
||||
## 🎯 核心特性
|
||||
|
||||
✅ **混合编译** - GitHub Actions 快速(8分钟),本地兼容
|
||||
✅ **Pre-release 支持** - 测试环境完全隔离
|
||||
✅ **Nginx 直接代理** - 无需后端 Controller
|
||||
✅ **自动回滚** - 更新失败自动恢复
|
||||
✅ **进程独立** - 更新服务与主应用分离
|
||||
✅ **版本追踪** - /app/version.json 记录
|
||||
✅ **权限控制** - 管理员权限验证
|
||||
|
||||
---
|
||||
|
||||
**实施完成时间**: 2026-01-21
|
||||
**技术方案版本**: v1.0
|
||||
@@ -0,0 +1,323 @@
|
||||
# 跟单回测功能产品需求文档 (PRD)
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
跟单回测功能允许用户对历史数据进行模拟跟单交易,评估不同跟单策略的收益表现,帮助用户在实际投入资金前验证策略的有效性。
|
||||
|
||||
## 二、用户故事
|
||||
|
||||
### 主要用户场景
|
||||
|
||||
1. **作为用户**,我希望能够创建回测任务,选择特定的 Leader 和跟单配置,以便评估在过去一段时间内使用该策略的收益情况
|
||||
2. **作为用户**,我希望能够查看所有历史回测结果,并按收益额或收益率排序,以便找到最优策略
|
||||
3. **作为用户**,我希望能够按 Leader 筛选回测记录,以便比较不同 Leader 的表现
|
||||
4. **作为用户**,我希望回测结果能够展示详细的交易记录和收益变化,以便深入分析策略表现
|
||||
5. **作为用户**,我希望回测在资金不足时能够自动停止,模拟真实交易场景
|
||||
|
||||
## 三、功能需求
|
||||
|
||||
### 3.1 回测管理页面
|
||||
|
||||
#### 3.1.1 页面布局
|
||||
|
||||
- **页面位置**: 在跟单管理模块下新增"回测管理"菜单项
|
||||
- **页面标题**: "回测管理" / "Backtest Management"
|
||||
|
||||
#### 3.1.2 列表功能
|
||||
|
||||
**筛选功能**:
|
||||
- Leader 筛选下拉框,支持按 Leader 过滤回测记录
|
||||
- 状态筛选: 全部 / 运行中 / 已完成 / 已停止
|
||||
|
||||
**排序功能**:
|
||||
- 按收益额排序 (升序/降序)
|
||||
- 按收益率排序 (升序/降序)
|
||||
- 按创建时间排序 (默认降序)
|
||||
|
||||
**列表字段**:
|
||||
| 字段名 | 说明 | 示例 |
|
||||
|-------|------|------|
|
||||
| 回测ID | 唯一标识 | #12345 |
|
||||
| 配置名称 | 回测任务名称 | "激进策略-Leader A" |
|
||||
| Leader名称 | 跟单的Leader | "Smart Trader" |
|
||||
| 初始金额 | 回测起始资金 | $1000 |
|
||||
| 最终金额 | 回测结束时资金 | $1250 |
|
||||
| 收益额 | 最终金额 - 初始金额 | +$250 |
|
||||
| 收益率 | (收益额/初始金额) × 100% | +25% |
|
||||
| 回测天数 | 回测的时间跨度 | 30天 |
|
||||
| 交易笔数 | 回测期间执行的交易数量 | 45笔 |
|
||||
| 状态 | 运行中/已完成/已停止 | 已完成 |
|
||||
| 开始时间 | 回测开始时间 | 2026-01-01 10:00 |
|
||||
| 结束时间 | 回测结束时间 | 2026-01-31 15:30 |
|
||||
| 操作 | 查看详情/删除 | - |
|
||||
|
||||
#### 3.1.3 列表操作
|
||||
|
||||
- **查看详情**: 点击后展开详细信息,包括:
|
||||
- 回测配置参数
|
||||
- 详细交易记录
|
||||
- 资金变化曲线图
|
||||
- 收益统计
|
||||
- **删除**: 删除回测记录(确认后不可恢复)
|
||||
|
||||
### 3.2 新增回测任务
|
||||
|
||||
#### 3.2.1 创建入口
|
||||
|
||||
- 列表页面右上角"新增回测"按钮
|
||||
- 点击后弹出创建对话框或跳转到创建页面
|
||||
|
||||
#### 3.2.2 配置表单
|
||||
|
||||
**基本配置**:
|
||||
- **回测名称** (必填): 用户自定义名称,方便识别
|
||||
- **选择Leader** (必填): 下拉框选择已添加的Leader
|
||||
- **初始投入金额** (必填): 模拟起始资金,单位USDC,范围: 1 - 1,000,000
|
||||
- **回测天数** (必填): 选择回测的历史天数,范围: 1 - 30天
|
||||
|
||||
**跟单配置** (参照现有跟单配置参数):
|
||||
|
||||
| 配置项 | 字段名 | 说明 | 默认值 |
|
||||
|-------|-------|------|-------|
|
||||
| 跟单模式 | copyMode | RATIO(比例模式) / FIXED(固定金额) | RATIO |
|
||||
| 跟单比例 | copyRatio | 比例模式下生效,相对Leader订单金额的比例 | 1.0 |
|
||||
| 固定金额 | fixedAmount | 固定金额模式下生效,每笔固定投入金额 | null |
|
||||
| 最大单笔订单 | maxOrderSize | 单笔订单最大金额限制 | 1000 |
|
||||
| 最小单笔订单 | minOrderSize | 单笔订单最小金额限制 | 1 |
|
||||
| 最大每日亏损 | maxDailyLoss | 每日最大亏损限制 | 10000 |
|
||||
| 最大每日订单数 | maxDailyOrders | 每日最大订单数量限制 | 100 |
|
||||
| 价格容忍度 | priceTolerance | 价格偏差容忍百分比 | 5% |
|
||||
| 延迟秒数 | delaySeconds | 跟单延迟时间 | 0 |
|
||||
| 支持卖出 | supportSell | 是否跟随卖出 | true |
|
||||
| 最小订单深度 | minOrderDepth | 订单簿深度要求 | null |
|
||||
| 最大价差 | maxSpread | 买卖价差限制 | null |
|
||||
| 最低价格 | minPrice | 最低价格限制 | null |
|
||||
| 最高价格 | maxPrice | 最高价格限制 | null |
|
||||
| 最大仓位金额 | maxPositionValue | 最大持仓总金额 | null |
|
||||
| 关键字过滤模式 | keywordFilterMode | DISABLED/WHITELIST/BLACKLIST | DISABLED |
|
||||
| 关键字列表 | keywords | 关键字数组 | [] |
|
||||
| 市场截止时间限制 | maxMarketEndDate | 市场结束时间限制 | null |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 跟单配置表单应完全复用现有的跟单配置组件,保持参数一致性
|
||||
|
||||
#### 3.2.3 表单验证
|
||||
|
||||
- 回测名称: 不能为空,长度1-100字符
|
||||
- Leader: 必须选择有效的Leader
|
||||
- 初始金额: 必须大于0
|
||||
- 回测天数: 必须在1-30之间
|
||||
- 其他配置参数: 遵循现有跟单配置的验证规则
|
||||
|
||||
#### 3.2.4 提交逻辑
|
||||
|
||||
1. 表单验证通过后,提交到后端API
|
||||
2. 后端保存回测任务到数据库,状态设置为"待执行"
|
||||
3. 前端显示创建成功提示,自动跳转到列表页面
|
||||
4. 回测任务由后端轮询服务自动获取并执行
|
||||
|
||||
### 3.3 回测详情页面
|
||||
|
||||
#### 3.3.1 页面布局
|
||||
|
||||
**顶部概览卡片**:
|
||||
- 回测名称
|
||||
- Leader信息
|
||||
- 初始金额 / 最终金额
|
||||
- 收益额 / 收益率
|
||||
- 回测时间范围
|
||||
- 总交易笔数
|
||||
- 状态
|
||||
|
||||
**资金变化图表**:
|
||||
- X轴: 时间
|
||||
- Y轴: 账户余额
|
||||
- 折线图展示资金随时间的变化
|
||||
|
||||
**交易记录表格**:
|
||||
| 时间 | 市场 | 方向 | 数量 | 价格 | 金额 | 盈亏 | 余额 |
|
||||
|-----|------|-----|------|------|------|------|------|
|
||||
| 2026-01-01 10:05 | BTC > $100k | 买入 YES | 100 | 0.65 | 65 | - | 935.00 |
|
||||
| 2026-01-01 14:20 | BTC > $100k | 卖出 YES | 100 | 0.72 | 72 | +7.00 | 942.00 |
|
||||
| 2026-01-05 16:00 | ETH > $5k | 买入 YES | 50 | 0.80 | 40 | - | 902.00 |
|
||||
| 2026-01-10 00:00 | ETH > $5k | 市场结算 YES | 50 | 1.00 | 50 | +10.00 | 952.00 |
|
||||
|
||||
> [!NOTE]
|
||||
> **交易类型说明**:
|
||||
> - **买入**: 跟随Leader买入
|
||||
> - **卖出**: 跟随Leader卖出
|
||||
> - **市场结算**: 市场到期自动结算(赎回)
|
||||
>
|
||||
> **手续费**: 回测不计算手续费,简化计算逻辑
|
||||
|
||||
**统计数据**:
|
||||
- 总交易笔数
|
||||
- 买入笔数 / 卖出笔数
|
||||
- 胜率 (盈利交易 / 总交易)
|
||||
- 平均收益
|
||||
- 最大单笔盈利
|
||||
- 最大单笔亏损
|
||||
- 最大回撤
|
||||
|
||||
## 四、业务规则
|
||||
|
||||
### 4.1 回测执行规则
|
||||
|
||||
#### 4.1.1 资金检查
|
||||
- **停止条件**: 当账户余额 < $1 **且无任何持仓**时,回测自动停止
|
||||
- **继续条件**: 如果余额 < $1 但仍有持仓,继续处理后续交易
|
||||
- 原因: Leader 可能卖出,或市场到期结算,释放资金
|
||||
- 处理: 跳过无法执行的买入订单,继续处理卖出和结算
|
||||
- **订单检查**: 每次下单前检查余额是否充足
|
||||
- **不足处理**: 余额不足时跳过该买入订单,记录日志,继续监听后续交易
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 只要有持仓存在,就不应停止回测,因为后续可能通过卖出或市场结算回收资金
|
||||
|
||||
#### 4.1.2 历史数据获取
|
||||
- 从 Polymarket API 获取 Leader 的历史交易记录
|
||||
- 根据回测天数计算起始时间: `startTime = now - (backtestDays × 24 × 3600 × 1000)`
|
||||
- 按时间顺序回放交易
|
||||
|
||||
#### 4.1.3 交易执行模拟
|
||||
- 按照配置的跟单规则计算跟单金额
|
||||
- 应用所有过滤条件 (价格、深度、关键字等)
|
||||
- 模拟订单成交(不计算手续费)
|
||||
- 更新账户余额
|
||||
|
||||
> [!NOTE]
|
||||
> 回测不计算手续费,简化计算逻辑,避免过于复杂的精度问题
|
||||
|
||||
#### 4.1.3.1 余额不足的处理 ⚠️
|
||||
|
||||
**场景说明**:
|
||||
- 当前余额不足以执行买入订单
|
||||
- 但有持仓未卖出(相当于"待赎回资产")
|
||||
|
||||
**处理策略**:
|
||||
|
||||
**方案A: 严格模式**(推荐)
|
||||
- ✅ 仅使用当前可用余额(`currentBalance`)
|
||||
- ✅ 余额不足时跳过该订单,不考虑持仓价值
|
||||
- ✅ 理由: 更保守,模拟真实场景(持仓未卖出前资金不可用)
|
||||
|
||||
**方案B: 宽松模式**(可选)
|
||||
- 计算"潜在可用资金" = `currentBalance + 持仓市值`
|
||||
- 允许"透支"买入,后续通过卖出或结算平衡
|
||||
- 风险: 可能产生不切实际的回测结果
|
||||
|
||||
**推荐实现**:
|
||||
```kotlin
|
||||
// 严格检查余额
|
||||
if (totalCost > currentBalance) {
|
||||
logger.info("余额不足以执行买入订单: 需要 $totalCost, 可用 $currentBalance")
|
||||
logger.debug("当前持仓价值: ${calculatePositionValue(positions)}, 但不计入可用余额")
|
||||
continue // 跳过该订单
|
||||
}
|
||||
```
|
||||
|
||||
**特殊情况: 市场即将结算**
|
||||
- 如果持仓市场在接下来很短时间内会结算,可以提前释放资金
|
||||
- 实现: 在每次交易前先执行市场结算检查(已在4.1.5实现)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 采用**严格模式**更符合真实跟单场景,避免回测结果过于乐观
|
||||
|
||||
#### 4.1.4 卖出跟随
|
||||
- 如果 `supportSell = true`,跟随 Leader 的卖出操作
|
||||
- 按照买入时的比例卖出持仓
|
||||
- 计算盈亏并更新余额
|
||||
|
||||
#### 4.1.5 市场结算处理 ⭐
|
||||
- **触发时机**:
|
||||
- **实时检查**: 在处理每笔Leader交易前,检查所有持仓市场的`endDate`
|
||||
- **到期即结算**: 如果市场结束时间 ≤ 当前交易时间,立即结算该持仓
|
||||
- **兜底处理**: 回测结束时,结算所有剩余持仓
|
||||
- **结算规则**:
|
||||
- 获取市场最终结果 (通过Polymarket API)
|
||||
- 持仓方向为胜出方: 按 **1.0** 价格结算
|
||||
- 持仓方向为失败方: 按 **0.0** 价格结算
|
||||
- 市场未结算或无法获取结果: 按**成本价**结算 (保守估计)
|
||||
- **资金流转**: 结算后的资金立即计入余额,可用于后续交易
|
||||
- **交易记录**: 生成"市场结算"类型的交易记录,用于详情展示
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **实时结算的优势**:
|
||||
> - ✅ 模拟真实场景: 市场结束时资金会自动返还
|
||||
> - ✅ 提高资金利用率: 结算后的资金可以参与后续交易
|
||||
> - ✅ 更准确的收益计算: 反映实际的资金周转情况
|
||||
|
||||
### 4.2 数据持久化
|
||||
|
||||
#### 4.2.1 回测任务表
|
||||
- 保存回测基本信息和配置
|
||||
- 记录执行状态和结果
|
||||
|
||||
#### 4.2.2 回测交易记录表
|
||||
- 保存每笔模拟交易的详细信息
|
||||
- 用于详情页面展示和分析
|
||||
|
||||
### 4.3 并发控制
|
||||
|
||||
- 同一时间最多支持 5 个回测任务并发执行
|
||||
- 新任务排队等待,FIFO策略
|
||||
- 前端显示任务队列位置
|
||||
|
||||
## 五、UI/UX 要求
|
||||
|
||||
### 5.1 响应式设计
|
||||
- 支持桌面和移动端浏览
|
||||
- 表格在小屏幕上支持横向滚动
|
||||
|
||||
### 5.2 国际化
|
||||
- 支持中文和英文
|
||||
- 所有文案提供双语版本
|
||||
|
||||
### 5.3 交互体验
|
||||
- 创建回测: 表单提交时显示Loading状态
|
||||
- 回测执行中: 显示进度条或百分比
|
||||
- 数据加载: Skeleton占位符
|
||||
- 操作反馈: Toast提示 (成功/失败/警告)
|
||||
|
||||
### 5.4 数据可视化
|
||||
- 资金变化图表使用 ECharts 或 Recharts
|
||||
- 支持图表缩放和数据点Tooltip
|
||||
- 图表颜色: 盈利绿色,亏损红色
|
||||
|
||||
## 六、非功能需求
|
||||
|
||||
### 6.1 性能要求
|
||||
- 回测列表页面加载时间 < 2秒
|
||||
- 单个回测任务执行时间 < 5分钟 (30天数据)
|
||||
- 详情页图表渲染时间 < 1秒
|
||||
|
||||
### 6.2 数据准确性
|
||||
- 回测结果误差 < 0.1%
|
||||
- 余额计算使用 BigDecimal 避免精度丢失
|
||||
- 价格和数量精确到小数点后8位
|
||||
|
||||
### 6.3 安全性
|
||||
- 回测数据仅用户本人可见
|
||||
- API接口需要身份认证
|
||||
- 防止SQL注入和XSS攻击
|
||||
|
||||
## 七、后续迭代规划
|
||||
|
||||
### Phase 2 (可选)
|
||||
- 支持批量创建回测任务
|
||||
- 回测结果对比功能
|
||||
- 导出回测报告 (PDF/Excel)
|
||||
- AI策略推荐
|
||||
|
||||
### Phase 3 (可选)
|
||||
- 实时回测 (边交易边回测)
|
||||
- 社区策略分享
|
||||
- 策略市场
|
||||
|
||||
---
|
||||
|
||||
## 附录: 页面路由规划
|
||||
|
||||
- 回测列表: `/copy-trading/backtest`
|
||||
- 新增回测: `/copy-trading/backtest/create`
|
||||
- 回测详情: `/copy-trading/backtest/:id`
|
||||
@@ -0,0 +1,669 @@
|
||||
# 回测功能设计审查清单
|
||||
|
||||
## 一、设计审查要点
|
||||
|
||||
### 1.1 产品需求完整性 ✅
|
||||
|
||||
**已覆盖的核心功能**:
|
||||
- ✅ 回测任务的创建、查询、删除
|
||||
- ✅ 按Leader筛选和排序功能
|
||||
- ✅ 回测配置参数复用现有跟单配置
|
||||
- ✅ 回测详情展示 (交易记录、资金曲线图、统计数据)
|
||||
- ✅ 资金不足时自动停止机制
|
||||
- ✅ 回测天数限制 (1-30天)
|
||||
|
||||
**潜在遗漏点**:
|
||||
> [!WARNING]
|
||||
> **需要确认的问题**:
|
||||
> 1. **回测结果的可见性**: 是否需要支持多用户? 当前设计未涉及权限控制
|
||||
> 2. **回测任务的生命周期管理**: 是否需要自动清理过期的回测记录?
|
||||
> 3. **回测进度的实时展示**: 前端如何获取运行中任务的进度? (考虑WebSocket或轮询)
|
||||
|
||||
### 1.2 技术设计合理性 ✅
|
||||
|
||||
**优点**:
|
||||
- ✅ 数据库设计规范,索引合理
|
||||
- ✅ API设计符合RESTful规范
|
||||
- ✅ 复用现有的 `CopyTradingFilterService`,减少代码冗余
|
||||
- ✅ 使用 BigDecimal 保证计算精度
|
||||
- ✅ 异步执行回测任务,不阻塞主线程
|
||||
|
||||
**可能的改进点**:
|
||||
> [!NOTE]
|
||||
> **建议优化的地方**:
|
||||
> 1. **历史数据获取**: 当前设计依赖Polymarket API,需要考虑API限流和数据缺失的情况
|
||||
> 2. **缓存策略**: 建议对Leader历史交易数据使用分层缓存 (内存 + Redis)
|
||||
> 3. **回测结果的序列化**: 考虑将详细交易记录存储为JSON,减少表的大小
|
||||
|
||||
### 1.3 业务逻辑准确性 ✅
|
||||
|
||||
**已完善的关键逻辑**:
|
||||
|
||||
#### 1.3.1 历史数据获取 ⭐ (已修正)
|
||||
> [!NOTE]
|
||||
> **问题**: 现有 `ProcessedTrade` 表字段有限,无法满足回测需求。
|
||||
>
|
||||
> **解决方案**: 创建独立的 `backtest_historical_trades` 表
|
||||
> - ✅ 存储完整的交易信息(marketId, price, quantity, outcomeIndex 等)
|
||||
> - ✅ 支持实时数据同步(跟单时同时写入)
|
||||
> - ✅ 支持通过 API 补充历史数据
|
||||
> - ✅ 不影响现有跟单功能
|
||||
|
||||
#### 1.3.2 卖出匹配逻辑 ⭐ (已修正)
|
||||
> [!NOTE]
|
||||
> **改进**: 使用 `outcomeIndex` 支持多元市场
|
||||
>
|
||||
> **实现方案**:
|
||||
> - 持仓键: `marketId + outcomeIndex`(支持多元市场)
|
||||
> - 比例模式: 按 Leader 卖出比例计算
|
||||
> - 固定金额模式: 全部卖出
|
||||
> - 参考 `CopyOrderTracking` 的逻辑
|
||||
|
||||
**伪代码**:
|
||||
```kotlin
|
||||
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
|
||||
val position = positions[positionKey] ?: continue
|
||||
|
||||
val sellQuantity = if (task.copyMode == "RATIO") {
|
||||
if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
|
||||
position.quantity * (leaderTrade.quantity / position.leaderBuyQuantity)
|
||||
} else {
|
||||
position.quantity // 全部卖出
|
||||
}
|
||||
} else {
|
||||
position.quantity // 固定金额模式全部卖出
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3.3 价格滑点模拟 ✅ (已决策)
|
||||
> [!NOTE]
|
||||
> **用户决策**: 暂不模拟价格滑点
|
||||
>
|
||||
> **理由**:
|
||||
> - 简化回测逻辑
|
||||
> - 减少复杂度
|
||||
> - 后续可以作为可选项添加
|
||||
>
|
||||
> **实现**: 使用 Leader 的成交价,不进行滑点调整
|
||||
|
||||
#### 1.3.2 价格滑点模拟
|
||||
> [!NOTE]
|
||||
> **关键问题**: 是否需要模拟价格滑点?
|
||||
>
|
||||
> **当前设计**: 不模拟价格滑点,直接使用Leader的成交价
|
||||
> - 优点: 简化逻辑,回测速度快
|
||||
> - 缺点: 可能高估收益(实际跟单可能有滑点)
|
||||
>
|
||||
> **可选方案**: 增加可配置的滑点参数
|
||||
> - 买入时: 价格 × (1 + 滑点%)
|
||||
> - 卖出时: 价格 × (1 - 滑点%)
|
||||
> - 增加可选的滑点模拟参数 (例如: ±0.5%)
|
||||
> - 在PRD中补充此配置项
|
||||
|
||||
#### 1.3.3 手续费计算 ✅ (已移除)
|
||||
> [!NOTE]
|
||||
> **用户决策**: 回测不计算手续费
|
||||
>
|
||||
> **理由**:
|
||||
> - 简化计算逻辑
|
||||
> - 避免精度问题
|
||||
> - 降低复杂度
|
||||
>
|
||||
> **实现**:
|
||||
> - 所有交易的 `fee` 字段均为 `0`
|
||||
> - 买入成本 = 数量 × 价格
|
||||
> - 卖出收入 = 数量 × 价格
|
||||
> - 结算收入 = 数量 × 结算价
|
||||
|
||||
#### 1.3.4 市场结算处理 ⭐ (已优化)
|
||||
> [!NOTE]
|
||||
> **问题**: 市场结束时,未平仓位如何自动结算?
|
||||
>
|
||||
> **优化方案** (采纳用户建议):
|
||||
> - ✅ **实时检查**: 在回测循环中,每处理一笔Leader交易前,检查所有持仓的市场`endDate`
|
||||
> - ✅ **到期即结算**: 如果 `marketEndDate <= currentTradeTime`,立即结算该持仓
|
||||
> - ✅ **资金可用**: 结算后的资金立即计入余额,可以用于后续交易
|
||||
> - ✅ **兜底处理**: 回测结束时,结算所有剩余未到期持仓
|
||||
>
|
||||
> **结算价格判断** (通过市场价格):
|
||||
> - 价格 >= 0.95: 胜出 (按 1.0 结算)
|
||||
> - 价格 <= 0.05: 失败 (按 0.0 结算)
|
||||
> - 其他情况: 按成本价保守估计
|
||||
>
|
||||
> **实现要点**:
|
||||
> ```kotlin
|
||||
> // 在交易循环中实时检查市场到期
|
||||
> for (leaderTrade in leaderTrades.sortedBy { it.timestamp }) {
|
||||
>
|
||||
> // 1. 检查并结算已到期的市场
|
||||
> val expiredPositions = positions.filter { (_, position) ->
|
||||
> val marketInfo = getMarketInfo(position.marketId)
|
||||
> marketInfo.endDate <= leaderTrade.timestamp
|
||||
> }
|
||||
>
|
||||
> for ((positionKey, position) in expiredPositions) {
|
||||
> val marketPrice = marketPriceService.getCurrentMarketPrice(
|
||||
> marketId = position.marketId,
|
||||
> outcomeIndex = position.outcomeIndex ?: 0
|
||||
> )
|
||||
>
|
||||
> val settlementPrice = when {
|
||||
> marketPrice >= BigDecimal("0.95") -> BigDecimal.ONE // 胜出
|
||||
> marketPrice <= BigDecimal("0.05") -> BigDecimal.ZERO // 失败
|
||||
> else -> position.avgPrice // 未结算,按成本价
|
||||
> }
|
||||
>
|
||||
> val settlementValue = position.quantity * settlementPrice
|
||||
> currentBalance += settlementValue
|
||||
> positions.remove(positionKey)
|
||||
> }
|
||||
>
|
||||
> // 2. 处理当前Leader交易
|
||||
> // ...
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **优势**:
|
||||
> - 更符合真实场景 (市场结束时自动返还资金)
|
||||
> - 提高资金利用率 (结算资金可参与后续交易)
|
||||
> - 更准确的收益计算
|
||||
> - 通过市场价格判断,无需依赖可能不存在的 `winner` 字段
|
||||
|
||||
#### 1.3.5 余额不足与持仓处理 ⚠️ (边缘场景) - 已修正
|
||||
> [!WARNING]
|
||||
> **关键问题**: 当余额不足但有未卖出持仓时,如何处理?
|
||||
>
|
||||
> **场景示例**:
|
||||
> - 初始余额: $1000
|
||||
> - 已买入持仓市值: $800(未卖出)
|
||||
> - 当前余额: $200
|
||||
> - 新买入订单需要: $300
|
||||
> - **问题**: 是否允许买入?虽然持仓市值足够,但资金被占用
|
||||
>
|
||||
> **推荐方案: 严格模式**
|
||||
> - ❌ 不允许买入(余额不足)
|
||||
> - ✅ 仅使用 `currentBalance` 判断
|
||||
> - ✅ 不计入持仓市值(因为持仓未实现)
|
||||
> - ✅ 理由: 更真实,避免过于乐观的回测结果
|
||||
>
|
||||
> **替代方案: 宽松模式**
|
||||
> - ✅ 计算"虚拟可用资金" = `currentBalance + 持仓估值`
|
||||
> - ⚠️ 允许"透支"买入
|
||||
> - ❌ 风险: 可能产生不切实际的收益
|
||||
>
|
||||
> **实际影响**:
|
||||
> - 严格模式下,资金周转率是限制因素
|
||||
> - 鼓励快进快出的策略
|
||||
> - 长期持仓策略会因资金占用而错过后续机会
|
||||
>
|
||||
> **已在文档中采用**: 严格模式
|
||||
|
||||
#### 1.3.6 每日订单数限制 ✅ (已补充)
|
||||
> [!NOTE]
|
||||
> **问题**: 文档提到了 `maxDailyOrders` 参数,但未在算法中实现
|
||||
>
|
||||
> **解决方案**: 在回测循环中添加每日订单数统计
|
||||
>
|
||||
> **实现**:
|
||||
> ```kotlin
|
||||
> // 统计当前交易时间当天已有的订单数
|
||||
> val dailyOrderCount = trades.count { isSameDay(it.tradeTime, leaderTrade.timestamp) }
|
||||
>
|
||||
> if (dailyOrderCount >= task.maxDailyOrders) {
|
||||
> logger.info("已达到每日最大订单数限制: $dailyOrderCount / ${task.maxDailyOrders}")
|
||||
> continue
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **优势**:
|
||||
> - 符合实际跟单的风险控制逻辑
|
||||
> - 避免回测结果过于激进
|
||||
|
||||
#### 1.3.7 价格容忍度检查 ✅ (已补充)
|
||||
> [!NOTE]
|
||||
> **问题**: 文档提到了 `priceTolerance` 参数,但未在算法中实现
|
||||
>
|
||||
> **解决方案**: 在执行交易前检查当前市场价格是否在容忍范围内
|
||||
>
|
||||
> **实现**:
|
||||
> ```kotlin
|
||||
> if (task.priceTolerance > BigDecimal.ZERO) {
|
||||
> val tolerance = task.priceTolerance.divide(BigDecimal("100"))
|
||||
> val minPrice = leaderTrade.price.multiply(BigDecimal.ONE.subtract(tolerance))
|
||||
> val maxPrice = leaderTrade.price.multiply(BigDecimal.ONE.add(tolerance))
|
||||
>
|
||||
> val currentPrice = marketPriceService.getCurrentMarketPrice(
|
||||
> marketId = leaderTrade.marketId,
|
||||
> outcomeIndex = leaderTrade.outcomeIndex ?: 0
|
||||
> )
|
||||
>
|
||||
> if (currentPrice < minPrice || currentPrice > maxPrice) {
|
||||
> logger.info("价格超出容忍度范围: 当前=$currentPrice, 可用范围=[$minPrice, $maxPrice]")
|
||||
> continue
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
|
||||
#### 1.3.8 回测停止条件 ✅ (已修正)
|
||||
> [!NOTE]
|
||||
> **修正**: 基于用户反馈,修正了停止逻辑
|
||||
>
|
||||
> **错误设计**:
|
||||
> ```kotlin
|
||||
> if (currentBalance < $1) {
|
||||
> break // ❌ 直接停止,忽略持仓
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **正确设计**:
|
||||
> ```kotlin
|
||||
> // 只有"余额不足 且 无持仓"时才停止
|
||||
> if (currentBalance < $1 && positions.isEmpty()) {
|
||||
> break // ✅ 确保无持仓时才停止
|
||||
> }
|
||||
>
|
||||
> // 有持仓时继续处理(等待卖出或结算)
|
||||
> if (currentBalance < $1 && positions.isNotEmpty()) {
|
||||
> // 继续处理,跳过买入,但执行卖出和结算
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **理由**:
|
||||
> - 持仓存在意味着可能有后续卖出或市场结算
|
||||
> - 这些操作会释放资金
|
||||
> - 过早停止会导致资金无法回收,回测不准确
|
||||
|
||||
### 1.4 性能和可扩展性 ✅
|
||||
|
||||
**已考虑的优化**:
|
||||
- ✅ 异步执行,线程池限制并发
|
||||
- ✅ 分页查询
|
||||
- ✅ 数据库索引优化
|
||||
- ✅ 前端虚拟滚动
|
||||
|
||||
**需要进一步考虑**:
|
||||
> [!TIP]
|
||||
> **性能优化建议**:
|
||||
> 1. **批量插入交易记录**: 使用 `saveAll()` 而非逐条 `save()`
|
||||
> 2. **进度更新频率**: 避免每笔交易都更新数据库,改为每100笔或每10秒更新一次
|
||||
> 3. **历史数据预加载**: 在任务开始前一次性加载所有历史交易,避免多次API调用
|
||||
|
||||
## 二、数据库设计补充
|
||||
|
||||
### 2.1 新增回测历史交易表
|
||||
|
||||
**问题**: 现有 `ProcessedTrade` 表字段有限,无法满足回测需求。
|
||||
|
||||
**解决方案**: 创建独立的 `backtest_historical_trades` 表,存储完整的 Leader 历史交易数据。
|
||||
|
||||
```sql
|
||||
CREATE TABLE backtest_historical_trades (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID',
|
||||
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
|
||||
trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 交易ID(唯一标识)',
|
||||
market_id VARCHAR(100) NOT NULL COMMENT '市场ID',
|
||||
market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题',
|
||||
market_slug VARCHAR(200) DEFAULT NULL COMMENT '市场 slug(用于生成链接)',
|
||||
side VARCHAR(10) NOT NULL COMMENT '交易方向: BUY/SELL',
|
||||
outcome VARCHAR(50) DEFAULT NULL COMMENT '市场方向(如 YES, NO 等)',
|
||||
outcome_index INT DEFAULT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场',
|
||||
price DECIMAL(20, 8) NOT NULL COMMENT '交易价格',
|
||||
size DECIMAL(20, 8) NOT NULL COMMENT '交易数量',
|
||||
amount DECIMAL(20, 8) NOT NULL COMMENT '交易金额(price × size)',
|
||||
trade_timestamp BIGINT NOT NULL COMMENT '交易时间戳(毫秒)',
|
||||
|
||||
-- 元数据
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'POLLING' COMMENT '数据来源: WEBSOCKET/POLLING/API',
|
||||
fetched_at BIGINT NOT NULL COMMENT '数据获取时间(毫秒)',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒)',
|
||||
|
||||
UNIQUE INDEX uk_leader_trade (leader_id, trade_id),
|
||||
INDEX idx_leader_id (leader_id),
|
||||
INDEX idx_trade_timestamp (trade_timestamp),
|
||||
INDEX idx_market_id (market_id)
|
||||
) COMMENT='回测历史交易表';
|
||||
```
|
||||
|
||||
**优势**:
|
||||
- ✅ 不影响现有跟单系统的 `ProcessedTrade` 表
|
||||
- ✅ 存储完整的交易信息,满足回测需求
|
||||
- ✅ 支持实时数据同步(跟单时同时写入)
|
||||
- ✅ 支持通过 API 补充历史数据
|
||||
- ✅ 唯一索引自动去重
|
||||
|
||||
### 2.2 移除 max_position_count 字段
|
||||
|
||||
**问题**: 文档中包含 `max_position_count` 字段,但 V26 迁移已删除该字段。
|
||||
|
||||
**解决方案**: 从 `backtest_task` 表和相关 API 中移除该字段。
|
||||
|
||||
### 2.3 其他字段建议
|
||||
|
||||
#### `backtest_task` 表
|
||||
建议新增以下字段:
|
||||
|
||||
```sql
|
||||
-- 用于计算平均持仓时间
|
||||
avg_holding_time BIGINT DEFAULT NULL COMMENT '平均持仓时间(毫秒)',
|
||||
|
||||
-- 用于记录回测使用的数据源
|
||||
data_source VARCHAR(50) DEFAULT 'MIXED' COMMENT '数据源: INTERNAL/API/MIXED',
|
||||
|
||||
-- 用于记录回测执行的详细日志
|
||||
execution_log TEXT DEFAULT NULL COMMENT '执行日志(JSON格式)'
|
||||
```
|
||||
|
||||
### 2.4 索引优化
|
||||
|
||||
建议添加复合索引:
|
||||
```sql
|
||||
-- 用于按Leader和收益率查询
|
||||
CREATE INDEX idx_leader_profit ON backtest_task(leader_id, profit_rate DESC);
|
||||
|
||||
-- 用于按状态和创建时间查询
|
||||
CREATE INDEX idx_status_created ON backtest_task(status, created_at DESC);
|
||||
```
|
||||
|
||||
## 三、API设计补充
|
||||
|
||||
### 3.1 API 规范修正
|
||||
|
||||
**问题**: 文档中使用 GET/DELETE 方法,违反项目统一使用 POST 的规范。
|
||||
|
||||
**修正方案**:
|
||||
|
||||
```bash
|
||||
# ❌ 错误(使用 GET/DELETE)
|
||||
GET /api/backtest/tasks
|
||||
GET /api/backtest/tasks/{id}
|
||||
DELETE /api/backtest/tasks/{id}
|
||||
|
||||
# ✅ 正确(统一使用 POST)
|
||||
POST /api/backtest/tasks/list
|
||||
POST /api/backtest/tasks/detail
|
||||
POST /api/backtest/tasks/delete
|
||||
```
|
||||
|
||||
**完整的 API 列表**:
|
||||
|
||||
| 功能 | 方法 | 路径 | 说明 |
|
||||
|-----|------|------|------|
|
||||
| 创建回测 | POST | /api/backtest/tasks | 创建新的回测任务 |
|
||||
| 查询列表 | POST | /api/backtest/tasks/list | 分页查询回测任务列表 |
|
||||
| 查询详情 | POST | /api/backtest/tasks/detail | 查询单个回测任务详情 |
|
||||
| 查询交易 | POST | /api/backtest/tasks/trades | 查询回测的交易记录 |
|
||||
| 删除任务 | POST | /api/backtest/tasks/delete | 删除回测任务 |
|
||||
| 停止任务 | POST | /api/backtest/tasks/stop | 停止运行中的回测 |
|
||||
| 查询进度 | POST | /api/backtest/tasks/progress | 查询回测执行进度 |
|
||||
|
||||
### 3.2 缺失的API
|
||||
|
||||
建议新增以下API:
|
||||
|
||||
#### 3.2.1 查询回测进度 (实时更新)
|
||||
```
|
||||
POST /api/backtest/tasks/progress
|
||||
```
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"id": 12345
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"progress": 65,
|
||||
"currentBalance": "1150.00",
|
||||
"totalTrades": 30,
|
||||
"status": "RUNNING"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.2 批量删除回测任务
|
||||
```
|
||||
POST /api/backtest/tasks/batch-delete
|
||||
```
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"taskIds": [12345, 12346, 12347]
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.3 导出回测报告
|
||||
```
|
||||
POST /api/backtest/tasks/export
|
||||
```
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"id": 12345,
|
||||
"format": "csv" // 或 "pdf"
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 API错误码规范
|
||||
|
||||
建议统一错误码:
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|-------|------|
|
||||
| 40001 | 回测任务不存在 |
|
||||
| 40002 | Leader不存在 |
|
||||
| 40003 | 回测天数超出限制 |
|
||||
| 40004 | 初始金额无效 |
|
||||
| 40005 | 回测任务正在运行,无法删除 |
|
||||
| 50001 | 历史数据获取失败 |
|
||||
| 50002 | 回测执行失败 |
|
||||
|
||||
## 四、前端实现补充
|
||||
|
||||
### 4.1 状态轮询
|
||||
|
||||
对于运行中的回测任务,前端需要定时轮询进度:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (task.status === 'RUNNING') {
|
||||
const interval = setInterval(async () => {
|
||||
const progress = await backtestService.getProgress(task.id);
|
||||
setTask({ ...task, ...progress });
|
||||
}, 3000); // 每3秒轮询一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [task.status]);
|
||||
```
|
||||
|
||||
### 4.2 图表数据压缩
|
||||
|
||||
当交易记录过多时,图表数据需要压缩:
|
||||
|
||||
```typescript
|
||||
// 将数据按时间聚合为最多200个点
|
||||
const compressChartData = (trades: BacktestTrade[], maxPoints: number = 200) => {
|
||||
if (trades.length <= maxPoints) return trades;
|
||||
|
||||
const interval = Math.floor(trades.length / maxPoints);
|
||||
return trades.filter((_, index) => index % interval === 0);
|
||||
};
|
||||
```
|
||||
|
||||
### 4.3 国际化文案
|
||||
|
||||
需要在 `locales/` 目录下补充以下文案:
|
||||
|
||||
**zh-CN.json**:
|
||||
```json
|
||||
{
|
||||
"backtest": {
|
||||
"title": "回测管理",
|
||||
"createTask": "新增回测",
|
||||
"taskName": "回测名称",
|
||||
"leader": "Leader",
|
||||
"initialBalance": "初始金额",
|
||||
"backtestDays": "回测天数",
|
||||
"profitAmount": "收益额",
|
||||
"profitRate": "收益率",
|
||||
"status": {
|
||||
"pending": "待执行",
|
||||
"running": "运行中",
|
||||
"completed": "已完成",
|
||||
"stopped": "已停止",
|
||||
"failed": "失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 五、测试计划补充
|
||||
|
||||
### 5.1 单元测试
|
||||
|
||||
**需要测试的核心方法**:
|
||||
- `BacktestExecutionService.executeBacktest()` - 回测算法准确性
|
||||
- `BacktestExecutionService.calculateStatistics()` - 统计数据计算
|
||||
- `BacktestDataService.getLeaderHistoricalTrades()` - 历史数据获取
|
||||
|
||||
**测试用例示例**:
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test backtest with simple buy-sell scenario`() {
|
||||
// Given: 初始余额1000, Leader买入100@0.5, 卖出100@0.6
|
||||
val task = createTestTask(initialBalance = 1000.toBigDecimal())
|
||||
val trades = listOf(
|
||||
createBuyTrade(quantity = 100.toBigDecimal(), price = 0.5.toBigDecimal()),
|
||||
createSellTrade(quantity = 100.toBigDecimal(), price = 0.6.toBigDecimal())
|
||||
)
|
||||
|
||||
// When: 执行回测
|
||||
val result = executionService.executeBacktest(task)
|
||||
|
||||
// Then: 验证收益
|
||||
// 买入: 100 * 0.5 = 50, 手续费0.1, 总成本50.1
|
||||
// 卖出: 100 * 0.6 = 60, 手续费0.12, 净收入59.88
|
||||
// 盈利: 59.88 - 50.1 = 9.78
|
||||
// 最终余额: 1000 - 50.1 + 59.88 = 1009.78
|
||||
assertEquals(1009.78.toBigDecimal(), result.finalBalance)
|
||||
assertEquals(9.78.toBigDecimal(), result.profitAmount)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 集成测试
|
||||
|
||||
**测试场景**:
|
||||
1. 端到端测试: 创建任务 → 执行回测 → 查询结果
|
||||
2. 异常场景: 历史数据为空、API调用失败
|
||||
3. 边界条件: 余额刚好为0、单笔交易耗尽余额
|
||||
|
||||
### 5.3 性能测试
|
||||
|
||||
**测试指标**:
|
||||
- 30天历史数据 (假设1000笔交易) 的回测执行时间 < 5分钟
|
||||
- 并发5个回测任务时的系统资源占用
|
||||
- 查询包含10000笔交易的回测详情页面加载时间 < 2秒
|
||||
|
||||
## 六、风险评估和缓解方案
|
||||
|
||||
### 6.1 数据准确性风险
|
||||
|
||||
**风险**: 历史数据不完整或API返回数据有误
|
||||
|
||||
**缓解方案**:
|
||||
1. 数据验证: 检查返回数据的完整性 (是否有时间断层)
|
||||
2. 数据对比: 使用多个数据源交叉验证
|
||||
3. 错误标记: 回测结果标注数据质量等级
|
||||
|
||||
### 6.2 计算精度风险
|
||||
|
||||
**风险**: BigDecimal计算中的舍入误差累积
|
||||
|
||||
**缓解方案**:
|
||||
1. 统一舍入模式: 使用 `RoundingMode.HALF_UP`
|
||||
2. 精度测试: 编写专门的精度测试用例
|
||||
3. 误差补偿: 最终余额与理论值的误差 < 0.01 USDC
|
||||
|
||||
### 6.3 性能风险
|
||||
|
||||
**风险**: 大量回测任务导致系统负载过高
|
||||
|
||||
**缓解方案**:
|
||||
1. 任务队列: 使用异步任务队列 (可选: Redis Queue 或 RabbitMQ)
|
||||
2. 资源限流: 限制单用户最多创建10个待执行任务
|
||||
3. 自动清理: 定期清理30天前的回测记录
|
||||
|
||||
## 七、需要与用户确认的问题
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **关键决策点 - 需要用户反馈**:
|
||||
|
||||
### 7.1 卖出匹配策略
|
||||
**问题**: 当用户多次买入同一市场时,卖出应该匹配哪笔买入?
|
||||
|
||||
**选项**:
|
||||
- **选项A**: FIFO (先进先出) - 先卖出最早的买入
|
||||
- **选项B**: 加权平均 - 按平均成本价计算盈亏
|
||||
- **选项C**: 完全跟随Leader - Leader卖多少比例,我们也卖多少比例
|
||||
|
||||
**建议**: 选项C (完全跟随),与实际跟单逻辑保持一致
|
||||
|
||||
### 7.2 价格滑点模拟
|
||||
**问题**: 是否需要在回测中模拟价格滑点?
|
||||
|
||||
**选项**:
|
||||
- **选项A**: 不模拟,使用Leader成交价 (乐观估计)
|
||||
- **选项B**: 固定滑点 (如买入+0.5%, 卖出-0.5%)
|
||||
- **选项C**: 可配置滑点,用户自定义
|
||||
|
||||
**建议**: 选项C,增加灵活性
|
||||
|
||||
### 7.3 数据源选择
|
||||
**问题**: 历史数据来源?
|
||||
|
||||
**选项**:
|
||||
- **选项A**: 仅使用 Polymarket API
|
||||
- **选项B**: 优先使用系统记录的 `ProcessedTrade` 表,不足时调用API
|
||||
- **选项C**: 仅使用 `ProcessedTrade` 表 (限制回测范围为系统运行期间)
|
||||
|
||||
**建议**: 选项B,兼顾数据完整性和性能
|
||||
|
||||
### 7.4 回测结果保留时长
|
||||
**问题**: 回测记录保留多久?
|
||||
|
||||
**选项**:
|
||||
- **选项A**: 永久保留
|
||||
- **选项B**: 保留30天,自动清理
|
||||
- **选项C**: 用户手动删除,无自动清理
|
||||
|
||||
**建议**: 选项B,避免数据库膨胀
|
||||
|
||||
## 八、文档总结
|
||||
|
||||
### 已完成的文档
|
||||
1. ✅ **BACKTEST_PRD.md** - 产品需求文档
|
||||
2. ✅ **BACKTEST_TECHNICAL_DESIGN.md** - 技术设计文档
|
||||
3. ✅ **BACKTEST_REVIEW_CHECKLIST.md** - 设计审查清单 (本文档)
|
||||
|
||||
### 建议补充的文档 (可选)
|
||||
1. **BACKTEST_API_SPEC.md** - API接口规范 (从技术设计文档提取)
|
||||
2. **BACKTEST_DATABASE_MIGRATION.md** - 数据库迁移脚本
|
||||
3. **BACKTEST_TEST_PLAN.md** - 详细测试计划
|
||||
|
||||
### 下一步行动
|
||||
1. **用户Review**: 请用户审查以上文档,确认关键设计点
|
||||
2. **补充遗漏**: 根据用户反馈补充缺失部分
|
||||
3. **进入执行**: 用户确认后开始实施开发
|
||||
|
||||
---
|
||||
|
||||
**审查日期**: 2026-01-30
|
||||
**审查人**: AI Assistant
|
||||
**状态**: 待用户确认
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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` 去重。
|
||||
@@ -0,0 +1,76 @@
|
||||
# 跟单回测功能文档
|
||||
|
||||
## 📚 文档清单
|
||||
|
||||
本目录包含跟单回测功能的完整设计文档:
|
||||
|
||||
### 核心文档
|
||||
|
||||
1. **[BACKTEST_PRD.md](./BACKTEST_PRD.md)** - 产品需求文档
|
||||
- 功能概述与用户故事
|
||||
- UI/UX设计详细说明
|
||||
- 业务规则与数据要求
|
||||
|
||||
2. **[BACKTEST_TECHNICAL_DESIGN.md](./BACKTEST_TECHNICAL_DESIGN.md)** - 技术设计文档
|
||||
- 数据库表结构设计
|
||||
- RESTful API接口规范
|
||||
- 后端服务架构
|
||||
- 前端组件设计
|
||||
- 回测算法实现
|
||||
|
||||
3. **[BACKTEST_REVIEW_CHECKLIST.md](./BACKTEST_REVIEW_CHECKLIST.md)** - 设计审查清单
|
||||
- 设计完整性检查
|
||||
- 边缘场景处理
|
||||
- 风险评估与缓解
|
||||
|
||||
## 🎯 核心特性
|
||||
|
||||
- ✅ 完全复用现有跟单配置参数
|
||||
- ✅ 实时市场结算(按 `endDate` 检查)
|
||||
- ✅ 严格余额检查(避免过于乐观的回测)
|
||||
- ✅ 支持按Leader筛选、按收益排序
|
||||
- ✅ 详细的交易记录和资金曲线图
|
||||
|
||||
## 📖 阅读建议
|
||||
|
||||
**产品经理**: 先阅读 PRD,再查看审查清单中的关键决策点
|
||||
|
||||
**技术负责人**: 先阅读技术设计文档,再查看审查清单评估风险
|
||||
|
||||
**开发工程师**: 按顺序阅读所有文档,重点关注技术设计的实现细节
|
||||
|
||||
## 🔄 文档版本
|
||||
|
||||
- **创建日期**: 2026-01-30
|
||||
- **最后更新**: 2026-01-30
|
||||
- **当前版本**: v1.0
|
||||
|
||||
## 📝 关键设计决策
|
||||
|
||||
### 1. 市场结算机制
|
||||
- 采用**实时检查**方式:每笔交易前检查市场 `endDate`
|
||||
- 到期即结算,资金立即释放可用于后续交易
|
||||
|
||||
### 2. 余额检查策略
|
||||
- 采用**严格模式**:仅使用 `currentBalance`,不计入持仓市值
|
||||
- 停止条件:余额 < $1 **且** 无任何持仓
|
||||
|
||||
### 3. 数据源选择
|
||||
- 优先使用系统记录的 `ProcessedTrade` 表
|
||||
- 不足时调用 Polymarket API 补充历史数据
|
||||
|
||||
### 4. 代码复用策略
|
||||
- 后端:完全复用 `CopyTradingFilterService` 的所有过滤逻辑
|
||||
- 前端:复用跟单配置表单组件
|
||||
- 数据库:配置字段与 `CopyTrading` 表保持一致
|
||||
|
||||
## 🚀 下一步
|
||||
|
||||
完成文档审查后,可以:
|
||||
1. 创建 `implementation_plan.md` 详细规划实施步骤
|
||||
2. 开始开发(数据库表 → API → 前端页面)
|
||||
3. 单元测试和集成测试
|
||||
|
||||
---
|
||||
|
||||
**文档位置**: `/Users/wrbug/polyhermes/docs/zh/backtest/`
|
||||
Generated
+33
-13
@@ -11,6 +11,7 @@
|
||||
"antd": "^5.12.0",
|
||||
"antd-mobile": "^5.34.0",
|
||||
"axios": "^1.6.2",
|
||||
"echarts": "^6.0.0",
|
||||
"ethers": "^6.16.0",
|
||||
"i18next": "^25.7.1",
|
||||
"react": "^18.2.0",
|
||||
@@ -158,7 +159,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -1677,7 +1677,6 @@
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1743,7 +1742,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
|
||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
@@ -1940,7 +1938,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2259,7 +2256,6 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.25",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
@@ -2471,8 +2467,7 @@
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.19",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||
"peer": true
|
||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
@@ -2581,6 +2576,22 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
|
||||
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.258",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.258.tgz",
|
||||
@@ -2693,7 +2704,6 @@
|
||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -3372,7 +3382,6 @@
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
@@ -5441,7 +5450,6 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -5453,7 +5461,6 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -6021,7 +6028,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -6194,7 +6200,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -6325,6 +6330,21 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"antd": "^5.12.0",
|
||||
"antd-mobile": "^5.34.0",
|
||||
"axios": "^1.6.2",
|
||||
"echarts": "^6.0.0",
|
||||
"ethers": "^6.16.0",
|
||||
"i18next": "^25.7.1",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -32,6 +32,8 @@ import SystemSettings from './pages/SystemSettings'
|
||||
import ApiHealthStatus from './pages/ApiHealthStatus'
|
||||
import RpcNodeSettings from './pages/RpcNodeSettings'
|
||||
import Announcements from './pages/Announcements'
|
||||
import BacktestList from './pages/BacktestList'
|
||||
import BacktestDetail from './pages/BacktestDetail'
|
||||
import { wsManager } from './services/websocket'
|
||||
import type { OrderPushMessage } from './types'
|
||||
import { apiService } from './services/api'
|
||||
@@ -254,6 +256,8 @@ function App() {
|
||||
<Route path="/copy-trading/orders/sell/:copyTradingId" element={<ProtectedRoute><CopyTradingSellOrders /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading/orders/matched/:copyTradingId" element={<ProtectedRoute><CopyTradingMatchedOrders /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading/filtered-orders/:id" element={<ProtectedRoute><FilteredOrdersList /></ProtectedRoute>} />
|
||||
<Route path="/backtest" element={<ProtectedRoute><BacktestList /></ProtectedRoute>} />
|
||||
<Route path="/backtest/detail/:id" element={<ProtectedRoute><BacktestDetail /></ProtectedRoute>} />
|
||||
<Route path="/config" element={<ProtectedRoute><ConfigPage /></ProtectedRoute>} />
|
||||
<Route path="/positions" element={<ProtectedRoute><PositionList /></ProtectedRoute>} />
|
||||
<Route path="/statistics" element={<ProtectedRoute><Statistics /></ProtectedRoute>} />
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Layout as AntLayout, Menu, Drawer, Button, Modal } from 'antd'
|
||||
import { Layout as AntLayout, Menu, Drawer, Button, Modal, Tag } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import {
|
||||
@@ -19,16 +19,38 @@ import {
|
||||
TwitterOutlined,
|
||||
CheckCircleOutlined,
|
||||
SendOutlined,
|
||||
ApiOutlined, NotificationOutlined
|
||||
ApiOutlined,
|
||||
NotificationOutlined,
|
||||
LineChartOutlined
|
||||
} from '@ant-design/icons'
|
||||
import type { MenuProps } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
import { removeToken, getVersionText, getGitHubTagUrl } from '../utils'
|
||||
import { removeToken, getVersionText, getVersionInfo } from '../utils'
|
||||
import { wsManager } from '../services/websocket'
|
||||
import { apiClient } from '../services/api'
|
||||
import Logo from './Logo'
|
||||
|
||||
const { Header, Content, Sider } = AntLayout
|
||||
|
||||
// 添加动画样式
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
@keyframes versionUpdatePulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
`
|
||||
if (!document.head.querySelector('style[data-version-update-animation]')) {
|
||||
style.setAttribute('data-version-update-animation', 'true')
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
@@ -39,6 +61,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const location = useLocation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [hasUpdate, setHasUpdate] = useState(false)
|
||||
|
||||
// 获取当前选中的菜单项
|
||||
const getSelectedKeys = (): string[] => {
|
||||
@@ -49,7 +72,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const getInitialOpenKeys = (): string[] => {
|
||||
const path = location.pathname
|
||||
const keys: string[] = []
|
||||
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading')) {
|
||||
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||
keys.push('/copy-trading-management')
|
||||
}
|
||||
if (path.startsWith('/system-settings')) {
|
||||
@@ -64,7 +87,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
useEffect(() => {
|
||||
const path = location.pathname
|
||||
const keys: string[] = []
|
||||
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading')) {
|
||||
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||
keys.push('/copy-trading-management')
|
||||
}
|
||||
if (path.startsWith('/system-settings')) {
|
||||
@@ -72,6 +95,29 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
}
|
||||
setOpenKeys(keys)
|
||||
}, [location.pathname])
|
||||
|
||||
// 检查是否有新版本
|
||||
useEffect(() => {
|
||||
const checkUpdate = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/update/check')
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setHasUpdate(response.data.data.hasUpdate || false)
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默失败,不影响页面使用
|
||||
console.debug('检查更新失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时检查一次
|
||||
checkUpdate()
|
||||
|
||||
// 每5分钟检查一次
|
||||
const interval = setInterval(checkUpdate, 5 * 60 * 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
@@ -103,6 +149,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
key: '/templates',
|
||||
icon: <FileTextOutlined />,
|
||||
label: t('menu.templates')
|
||||
},
|
||||
{
|
||||
key: '/backtest',
|
||||
icon: <LineChartOutlined />,
|
||||
label: t('menu.backtest') || '回测'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -210,27 +261,32 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
size="normal"
|
||||
darkMode={true}
|
||||
/>
|
||||
<a
|
||||
href={getGitHubTagUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => {
|
||||
const version = getVersionText()
|
||||
if (version === 'dev') {
|
||||
e.preventDefault()
|
||||
<Tag
|
||||
color={hasUpdate ? 'warning' : 'success'}
|
||||
onClick={() => {
|
||||
if (hasUpdate) {
|
||||
navigate('/system-settings')
|
||||
}
|
||||
}}
|
||||
bordered={false}
|
||||
style={{
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'normal',
|
||||
textDecoration: 'none',
|
||||
cursor: getVersionText() === 'dev' ? 'default' : 'pointer'
|
||||
cursor: hasUpdate ? 'pointer' : 'default',
|
||||
fontSize: '8px',
|
||||
padding: '1px 6px',
|
||||
margin: 0,
|
||||
background: 'transparent',
|
||||
border: `1px solid ${hasUpdate ? '#faad14' : '#52c41a'}`,
|
||||
borderRadius: '4px',
|
||||
color: hasUpdate ? '#faad14' : '#52c41a',
|
||||
lineHeight: '1.4',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
title={getVersionText() === 'dev' ? '' : '查看版本发布'}
|
||||
title={hasUpdate ? '有新版本可用,点击前往系统更新' : '当前已是最新版本'}
|
||||
>
|
||||
v{getVersionText()}
|
||||
</a>
|
||||
{getVersionInfo().gitTag || `v${getVersionText()}`}
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<a
|
||||
@@ -322,34 +378,37 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px'
|
||||
}}>
|
||||
<span>PolyHermes</span>
|
||||
<a
|
||||
href={getGitHubTagUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => {
|
||||
const version = getVersionText()
|
||||
if (version === 'dev') {
|
||||
e.preventDefault()
|
||||
<Tag
|
||||
color={hasUpdate ? 'warning' : 'success'}
|
||||
onClick={() => {
|
||||
if (hasUpdate) {
|
||||
navigate('/system-settings')
|
||||
}
|
||||
}}
|
||||
bordered={false}
|
||||
style={{
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'normal',
|
||||
textDecoration: 'none',
|
||||
cursor: getVersionText() === 'dev' ? 'default' : 'pointer',
|
||||
lineHeight: '1',
|
||||
paddingBottom: '2px'
|
||||
cursor: hasUpdate ? 'pointer' : 'default',
|
||||
fontSize: '8px',
|
||||
padding: '1px 6px',
|
||||
margin: 0,
|
||||
background: 'transparent',
|
||||
border: `1px solid ${hasUpdate ? '#faad14' : '#52c41a'}`,
|
||||
borderRadius: '4px',
|
||||
color: hasUpdate ? '#faad14' : '#52c41a',
|
||||
lineHeight: '1.4',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
title={getVersionText() === 'dev' ? '' : '查看版本发布'}
|
||||
title={hasUpdate ? '有新版本可用,点击前往系统更新' : '当前已是最新版本'}
|
||||
>
|
||||
v{getVersionText()}
|
||||
</a>
|
||||
{getVersionInfo().gitTag || `v${getVersionText()}`}
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -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
|
||||
@@ -4,6 +4,7 @@
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"edit": "Edit",
|
||||
"viewDetail": "View Details",
|
||||
"delete": "Delete",
|
||||
"add": "Add",
|
||||
"search": "Search",
|
||||
@@ -15,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",
|
||||
@@ -33,7 +38,10 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page",
|
||||
"pageOf": "Page"
|
||||
"pageOf": "Page",
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending",
|
||||
"day": "day"
|
||||
},
|
||||
"account": {
|
||||
"title": "Account Management",
|
||||
@@ -45,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",
|
||||
@@ -114,6 +123,7 @@
|
||||
"importAccount": "Import Account",
|
||||
"accountName": "Account Name",
|
||||
"walletAddress": "Wallet Address",
|
||||
"walletType": "Wallet Type",
|
||||
"proxyAddress": "Proxy Wallet Address",
|
||||
"apiCredentials": "API Credentials",
|
||||
"balance": "Balance",
|
||||
@@ -170,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",
|
||||
@@ -205,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",
|
||||
@@ -239,6 +267,7 @@
|
||||
"templates": "Templates",
|
||||
"copyTradingConfig": "Copy Trading Config",
|
||||
"positions": "Position Management",
|
||||
"backtest": "Backtest",
|
||||
"statistics": "Statistics",
|
||||
"announcements": "Announcements",
|
||||
"users": "User Management",
|
||||
@@ -425,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",
|
||||
@@ -481,6 +516,38 @@
|
||||
"fetchFailed": "Failed to get Leader details",
|
||||
"invalidId": "Invalid Leader ID"
|
||||
},
|
||||
"leaderDetail": {
|
||||
"title": "Leader Details",
|
||||
"loading": "Loading...",
|
||||
"invalidId": "Invalid Leader ID",
|
||||
"fetchFailed": "Failed to get Leader details",
|
||||
"fetchBalanceFailed": "Failed to get balance",
|
||||
"noBalanceData": "No balance data",
|
||||
"basicInfo": "Basic Information",
|
||||
"leaderName": "Leader Name",
|
||||
"leaderAddress": "Wallet Address",
|
||||
"category": "Category",
|
||||
"copyTradingCount": "Copy Trading Count",
|
||||
"remark": "Remark",
|
||||
"createdAt": "Created At",
|
||||
"updatedAt": "Updated At",
|
||||
"website": "Website",
|
||||
"openWebsite": "Open Website",
|
||||
"balanceInfo": "Balance Information",
|
||||
"refresh": "Refresh",
|
||||
"availableBalance": "Available Balance",
|
||||
"positionBalance": "Position Asset",
|
||||
"totalBalance": "Total Balance",
|
||||
"positions": "Positions",
|
||||
"noPositions": "No positions",
|
||||
"market": "Market",
|
||||
"marketId": "Market ID",
|
||||
"side": "Side",
|
||||
"quantity": "Quantity",
|
||||
"avgPrice": "Avg Price",
|
||||
"currentValue": "Current Value",
|
||||
"pnl": "PnL"
|
||||
},
|
||||
"userList": {
|
||||
"title": "User Management",
|
||||
"username": "Username",
|
||||
@@ -686,9 +753,6 @@
|
||||
"maxPositionValue": "Max Position Value (USDC)",
|
||||
"maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable",
|
||||
"maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)",
|
||||
"maxPositionCount": "Max Position Count",
|
||||
"maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable",
|
||||
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
|
||||
"supportSell": "Support Sell",
|
||||
"supportSellTooltip": "Whether to copy Leader's sell orders. Enabled: copy both Leader's buy and sell orders; Disabled: only copy Leader's buy orders, ignore sell orders.",
|
||||
"pushFilteredOrders": "Push Filtered Orders",
|
||||
@@ -760,9 +824,6 @@
|
||||
"maxPositionValue": "Max Position Value (USDC)",
|
||||
"maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable",
|
||||
"maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)",
|
||||
"maxPositionCount": "Max Position Count",
|
||||
"maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable",
|
||||
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
|
||||
"keywordFilter": "Keyword Filter",
|
||||
"keywordFilterMode": "Filter Mode",
|
||||
"keywordFilterModeTooltip": "Select keyword filter mode. Whitelist: only copy markets containing keywords; Blacklist: do not copy markets containing keywords; Disabled: no keyword filtering. Keyword matching is case-insensitive.",
|
||||
@@ -808,7 +869,13 @@
|
||||
"noAccounts": "No accounts",
|
||||
"importAccount": "Import Account",
|
||||
"noLeaders": "No Leaders",
|
||||
"addLeader": "Add Leader"
|
||||
"addLeader": "Add Leader",
|
||||
"leaderAssetInfo": "Leader Asset Info",
|
||||
"loadingAssetInfo": "Loading asset info...",
|
||||
"totalAsset": "Total Assets",
|
||||
"availableBalance": "Available Balance",
|
||||
"positionAsset": "Position Assets",
|
||||
"fetchAssetInfoFailed": "Failed to fetch asset info"
|
||||
},
|
||||
"copyTradingEdit": {
|
||||
"title": "Edit Copy Trading Config",
|
||||
@@ -867,9 +934,6 @@
|
||||
"maxPositionValue": "Max Position Value (USDC)",
|
||||
"maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable",
|
||||
"maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)",
|
||||
"maxPositionCount": "Max Position Count",
|
||||
"maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable",
|
||||
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
|
||||
"keywordFilter": "Keyword Filter",
|
||||
"keywordFilterMode": "Filter Mode",
|
||||
"keywordFilterModeTooltip": "Select keyword filter mode. Whitelist: only copy markets containing keywords; Blacklist: do not copy markets containing keywords; Disabled: no keyword filtering. Keyword matching is case-insensitive.",
|
||||
@@ -934,11 +998,13 @@
|
||||
"orderbookEmpty": "Orderbook Empty",
|
||||
"priceRange": "Price Range Mismatch",
|
||||
"maxPositionValue": "Exceeds Max Position Value",
|
||||
"maxPositionCount": "Exceeds Max Position Count",
|
||||
"marketEndDate": "Market End Date Exceeds Limit",
|
||||
"keywordFilter": "Keyword Filter",
|
||||
"unknown": "Unknown Reason"
|
||||
},
|
||||
"noData": "No filtered orders"
|
||||
"orderDetails": "Order Details",
|
||||
"noData": "No filtered orders",
|
||||
"noFilteredOrders": "No filtered orders"
|
||||
},
|
||||
"copyTradingList": {
|
||||
"title": "Copy Trading Config Management",
|
||||
@@ -1099,6 +1165,7 @@
|
||||
"price": "Price",
|
||||
"amount": "Amount",
|
||||
"filterMarketId": "Filter Market ID",
|
||||
"filterMarketTitle": "Filter Market Title",
|
||||
"filterSide": "Filter Side",
|
||||
"filterStatus": "Filter Status",
|
||||
"filterSellOrderId": "Filter Sell Order ID",
|
||||
@@ -1109,12 +1176,15 @@
|
||||
"groupByMarket": "Group by Market",
|
||||
"expandAll": "Expand All",
|
||||
"collapseAll": "Collapse All",
|
||||
"allFullyMatched": "All Fully Matched",
|
||||
"partiallyMatched": "Partially Matched",
|
||||
"allFullySold": "All Fully Sold",
|
||||
"notSold": "Not Sold",
|
||||
"partiallySold": "Partially Sold",
|
||||
"orderCount": "Order Count",
|
||||
"totalAmount": "Total Amount",
|
||||
"totalPnl": "Total PnL",
|
||||
"statusBreakdown": "Status",
|
||||
"allFullyMatched": "All Fully Sold",
|
||||
"partiallyMatched": "Partially Sold",
|
||||
"totalPnl": "Total PnL",
|
||||
"markets": "markets",
|
||||
"fetchBuyOrdersFailed": "Failed to fetch buy orders",
|
||||
"fetchSellOrdersFailed": "Failed to fetch sell orders",
|
||||
@@ -1126,7 +1196,6 @@
|
||||
"totalMatchedOrders": "Total Matched Orders",
|
||||
"totalBuyAmount": "Total Buy Amount",
|
||||
"totalSellAmount": "Total Sell Amount",
|
||||
"totalPnl": "Total PnL",
|
||||
"totalRealizedPnl": "Total Realized PnL",
|
||||
"totalUnrealizedPnl": "Total Unrealized PnL",
|
||||
"winRate": "Win Rate",
|
||||
@@ -1194,5 +1263,141 @@
|
||||
"providerQuickNode": "QuickNode",
|
||||
"providerChainstack": "Chainstack",
|
||||
"providerGetBlock": "GetBlock"
|
||||
},
|
||||
"backtest": {
|
||||
"title": "Backtest",
|
||||
"taskName": "Task Name",
|
||||
"leader": "Leader",
|
||||
"initialBalance": "Initial Balance",
|
||||
"backtestDays": "Backtest Days",
|
||||
"status": "Status",
|
||||
"progress": "Progress",
|
||||
"startTime": "Start Time",
|
||||
"endTime": "End Time",
|
||||
"finalBalance": "Final Balance",
|
||||
"profitAmount": "Profit Amount",
|
||||
"profitRate": "Profit Rate",
|
||||
"totalTrades": "Total Trades",
|
||||
"buyTrades": "Buy Trades",
|
||||
"sellTrades": "Sell Trades",
|
||||
"winTrades": "Win Trades",
|
||||
"lossTrades": "Loss Trades",
|
||||
"winRate": "Win Rate",
|
||||
"maxProfit": "Max Profit",
|
||||
"maxLoss": "Max Loss",
|
||||
"maxDrawdown": "Max Drawdown",
|
||||
"avgHoldingTime": "Avg Holding Time",
|
||||
"statusPending": "Pending",
|
||||
"statusRunning": "Running",
|
||||
"statusCompleted": "Completed",
|
||||
"statusStopped": "Stopped",
|
||||
"statusFailed": "Failed",
|
||||
"createTask": "Create Backtest Task",
|
||||
"taskList": "Backtest Task List",
|
||||
"taskDetail": "Backtest Task Detail",
|
||||
"tradeRecords": "Trade Records",
|
||||
"config": "Configuration",
|
||||
"statistics": "Statistics",
|
||||
"chart": "Balance Chart",
|
||||
"createSuccess": "Created successfully",
|
||||
"createFailed": "Failed to create",
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"stop": "Stop",
|
||||
"stopSuccess": "Stopped successfully",
|
||||
"stopFailed": "Failed to stop",
|
||||
"retry": "Retry",
|
||||
"retrySuccess": "Retry successfully",
|
||||
"retryFailed": "Failed to retry",
|
||||
"retryConfirm": "Are you sure you want to retry this backtest task? It will continue from the breakpoint and preserve processed trades.",
|
||||
"deleteConfirm": "Are you sure you want to delete this backtest task?",
|
||||
"stopConfirm": "Are you sure you want to stop this backtest task?",
|
||||
"createCopyTrading": "Create Copy Trading",
|
||||
"createCopyTradingSuccess": "Copy trading config created successfully",
|
||||
"noTasks": "No backtest tasks",
|
||||
"noTrades": "No trade records",
|
||||
"fetchTasksFailed": "Failed to fetch task list",
|
||||
"fetchTaskDetailFailed": "Failed to fetch task detail",
|
||||
"fetchTradesFailed": "Failed to fetch trade records",
|
||||
"balanceChart": "Balance Change",
|
||||
"pnlChart": "PnL Change",
|
||||
"executionTime": "Execution Time",
|
||||
"runningDuration": "Running Duration",
|
||||
"estimatedRemaining": "Estimated Remaining",
|
||||
"leaderAddress": "Leader Address",
|
||||
"leaderName": "Leader Name",
|
||||
"copyMode": "Copy Mode",
|
||||
"copyModeRatio": "Ratio",
|
||||
"copyModeFixed": "Fixed Amount",
|
||||
"copyRatio": "Copy Ratio",
|
||||
"fixedAmount": "Fixed Amount",
|
||||
"maxOrderSize": "Max Order Size",
|
||||
"minOrderSize": "Min Order Size",
|
||||
"maxDailyLoss": "Max Daily Loss",
|
||||
"maxDailyOrders": "Max Daily Orders",
|
||||
"priceTolerance": "Price Tolerance",
|
||||
"delaySeconds": "Delay Seconds",
|
||||
"supportSell": "Copy Sell",
|
||||
"minOrderDepth": "Min Order Depth",
|
||||
"maxSpread": "Max Spread",
|
||||
"minPrice": "Min Price",
|
||||
"maxPrice": "Max Price",
|
||||
"maxPositionValue": "Max Position Value",
|
||||
"keywordFilterMode": "Keyword Filter Mode",
|
||||
"keywordFilterModeDisabled": "Disabled",
|
||||
"keywordFilterModeWhitelist": "Whitelist",
|
||||
"keywordFilterModeBlacklist": "Blacklist",
|
||||
"keywords": "Keywords",
|
||||
"maxMarketEndDate": "Market End Date Limit",
|
||||
"tradeTime": "Trade Time",
|
||||
"marketId": "Market ID",
|
||||
"marketTitle": "Market Title",
|
||||
"side": "Side",
|
||||
"sideBuy": "Buy",
|
||||
"sideSell": "Sell",
|
||||
"sideSettlement": "Settlement",
|
||||
"outcome": "Outcome",
|
||||
"quantity": "Quantity",
|
||||
"price": "Price",
|
||||
"amount": "Amount",
|
||||
"fee": "Fee",
|
||||
"profitLoss": "Profit/Loss",
|
||||
"balanceAfter": "Balance After",
|
||||
"leaderTradeId": "Leader Trade ID",
|
||||
"loading": "Loading...",
|
||||
"refreshing": "Refreshing...",
|
||||
"starting": "Starting...",
|
||||
"stopping": "Stopping...",
|
||||
"errorOccurred": "Error occurred",
|
||||
"retry": "Retry",
|
||||
"taskNameRequired": "Please enter task name",
|
||||
"leaderRequired": "Please select Leader",
|
||||
"initialBalanceRequired": "Please enter initial balance",
|
||||
"initialBalanceInvalid": "Initial balance must be greater than 0",
|
||||
"backtestDaysRequired": "Please enter backtest days",
|
||||
"backtestDaysInvalid": "Backtest days must be between 1-15",
|
||||
"copyRatioRequired": "Please enter copy ratio",
|
||||
"copyRatioInvalid": "Copy ratio must be between 0.01-10000",
|
||||
"copyRatioPlaceholder": "For example: 100 means 100% (1:1 copy), default 100%",
|
||||
"copyRatioTooltip": "Copy ratio represents the percentage of copy amount relative to Leader order amount. For example: 100% means 1:1 copy, 50% means half position copy, 200% means double copy",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "确定",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"viewDetail": "查看详情",
|
||||
"add": "添加",
|
||||
"refresh": "刷新",
|
||||
"search": "搜索",
|
||||
@@ -33,7 +34,14 @@
|
||||
"pageOf": "第",
|
||||
"success": "成功",
|
||||
"failed": "失败",
|
||||
"close": "关闭"
|
||||
"close": "关闭",
|
||||
"create": "创建",
|
||||
"copy": "复制",
|
||||
"copySuccess": "复制成功",
|
||||
"copyFailed": "复制失败",
|
||||
"ascending": "升序",
|
||||
"descending": "降序",
|
||||
"day": "天"
|
||||
},
|
||||
"login": {
|
||||
"title": "登录",
|
||||
@@ -113,6 +121,7 @@
|
||||
"importAccount": "导入账户",
|
||||
"accountName": "账户名称",
|
||||
"walletAddress": "钱包地址",
|
||||
"walletType": "钱包类型",
|
||||
"proxyAddress": "代理钱包地址",
|
||||
"apiCredentials": "API 凭证",
|
||||
"balance": "余额",
|
||||
@@ -170,8 +179,6 @@
|
||||
"accountImport": {
|
||||
"title": "导入账户",
|
||||
"back": "返回",
|
||||
"securityTip": "安全提示",
|
||||
"securityTipDesc": "私钥将存储在后端数据库中,请确保数据库访问安全。建议使用 HTTPS 连接。",
|
||||
"importMethod": "导入方式",
|
||||
"privateKey": "私钥",
|
||||
"mnemonic": "助记词",
|
||||
@@ -205,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 管理",
|
||||
@@ -216,7 +243,7 @@
|
||||
"walletAddress": "钱包地址",
|
||||
"category": "分类",
|
||||
"all": "全部",
|
||||
"copyTradingCount": "跟单关系数",
|
||||
"copyTradingCount": "跟单数",
|
||||
"createdAt": "创建时间",
|
||||
"action": "操作",
|
||||
"add": "添加",
|
||||
@@ -239,6 +266,7 @@
|
||||
"templates": "跟单模板",
|
||||
"copyTradingConfig": "跟单配置",
|
||||
"positions": "仓位管理",
|
||||
"backtest": "回测",
|
||||
"statistics": "统计信息",
|
||||
"announcements": "公告",
|
||||
"users": "用户管理",
|
||||
@@ -424,15 +452,21 @@
|
||||
"website": "网站",
|
||||
"openWebsite": "打开网页",
|
||||
"all": "全部",
|
||||
"copyTradingCount": "跟单关系数",
|
||||
"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",
|
||||
@@ -481,6 +515,38 @@
|
||||
"fetchFailed": "获取 Leader 详情失败",
|
||||
"invalidId": "Leader ID 无效"
|
||||
},
|
||||
"leaderDetail": {
|
||||
"title": "Leader 详情",
|
||||
"loading": "加载中...",
|
||||
"invalidId": "Leader ID 无效",
|
||||
"fetchFailed": "获取 Leader 详情失败",
|
||||
"fetchBalanceFailed": "获取余额失败",
|
||||
"noBalanceData": "暂无余额数据",
|
||||
"basicInfo": "基本信息",
|
||||
"leaderName": "Leader 名称",
|
||||
"leaderAddress": "钱包地址",
|
||||
"category": "分类",
|
||||
"copyTradingCount": "跟单数",
|
||||
"remark": "备注",
|
||||
"createdAt": "创建时间",
|
||||
"updatedAt": "更新时间",
|
||||
"website": "网站",
|
||||
"openWebsite": "打开网页",
|
||||
"balanceInfo": "余额信息",
|
||||
"refresh": "刷新",
|
||||
"availableBalance": "可用余额",
|
||||
"positionBalance": "仓位资产",
|
||||
"totalBalance": "总余额",
|
||||
"positions": "持仓列表",
|
||||
"noPositions": "暂无持仓",
|
||||
"market": "市场",
|
||||
"marketId": "市场ID",
|
||||
"side": "方向",
|
||||
"quantity": "数量",
|
||||
"avgPrice": "均价",
|
||||
"currentValue": "当前价值",
|
||||
"pnl": "盈亏"
|
||||
},
|
||||
"userList": {
|
||||
"title": "用户管理",
|
||||
"username": "用户名",
|
||||
@@ -686,9 +752,6 @@
|
||||
"maxPositionValue": "最大仓位金额 (USDC)",
|
||||
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
|
||||
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
|
||||
"maxPositionCount": "最大仓位数量",
|
||||
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
|
||||
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
|
||||
"supportSell": "跟单卖出",
|
||||
"supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。",
|
||||
"pushFilteredOrders": "推送已过滤订单",
|
||||
@@ -774,9 +837,6 @@
|
||||
"maxPositionValue": "最大仓位金额 (USDC)",
|
||||
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
|
||||
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
|
||||
"maxPositionCount": "最大仓位数量",
|
||||
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
|
||||
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
|
||||
"keywordFilter": "关键字过滤",
|
||||
"keywordFilterMode": "过滤模式",
|
||||
"keywordFilterModeTooltip": "选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤。关键字匹配不区分大小写。",
|
||||
@@ -808,7 +868,13 @@
|
||||
"noAccounts": "暂无账户",
|
||||
"importAccount": "导入账户",
|
||||
"noLeaders": "暂无 Leader",
|
||||
"addLeader": "添加 Leader"
|
||||
"addLeader": "添加 Leader",
|
||||
"leaderAssetInfo": "Leader 资产信息",
|
||||
"loadingAssetInfo": "加载资产信息中...",
|
||||
"totalAsset": "总资产",
|
||||
"availableBalance": "可用余额",
|
||||
"positionAsset": "仓位资产",
|
||||
"fetchAssetInfoFailed": "获取资产信息失败"
|
||||
},
|
||||
"copyTradingEdit": {
|
||||
"title": "编辑跟单配置",
|
||||
@@ -881,9 +947,6 @@
|
||||
"maxPositionValue": "最大仓位金额 (USDC)",
|
||||
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
|
||||
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
|
||||
"maxPositionCount": "最大仓位数量",
|
||||
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
|
||||
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
|
||||
"keywordFilter": "关键字过滤",
|
||||
"keywordFilterMode": "过滤模式",
|
||||
"keywordFilterModeTooltip": "选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤。关键字匹配不区分大小写。",
|
||||
@@ -934,11 +997,13 @@
|
||||
"orderbookEmpty": "订单簿为空",
|
||||
"priceRange": "价格区间不符",
|
||||
"maxPositionValue": "超过最大仓位金额",
|
||||
"maxPositionCount": "超过最大仓位数量",
|
||||
"marketEndDate": "市场截止时间超出限制",
|
||||
"keywordFilter": "关键字过滤",
|
||||
"unknown": "未知原因"
|
||||
},
|
||||
"noData": "暂无已过滤订单"
|
||||
"orderDetails": "订单详情",
|
||||
"noData": "暂无已过滤订单",
|
||||
"noFilteredOrders": "暂无已过滤订单"
|
||||
},
|
||||
"copyTradingList": {
|
||||
"title": "跟单配置管理",
|
||||
@@ -1080,8 +1145,8 @@
|
||||
"sellStatus": "卖出状态",
|
||||
"status": "状态",
|
||||
"statusFilled": "未成交",
|
||||
"statusPartiallySold": "部分成交",
|
||||
"statusFullySold": "全部成交",
|
||||
"statusPartiallySold": "部分卖出",
|
||||
"statusFullySold": "全部卖出",
|
||||
"realizedPnl": "已实现盈亏",
|
||||
"createdAt": "创建时间",
|
||||
"matchedAt": "匹配时间",
|
||||
@@ -1110,11 +1175,14 @@
|
||||
"groupByMarket": "按市场分组",
|
||||
"expandAll": "展开全部",
|
||||
"collapseAll": "折叠全部",
|
||||
"allFullyMatched": "全部成交",
|
||||
"partiallyMatched": "部分成交",
|
||||
"allFullySold": "全部卖出",
|
||||
"notSold": "未卖出",
|
||||
"partiallySold": "部分卖出",
|
||||
"orderCount": "订单数",
|
||||
"totalAmount": "总金额",
|
||||
"statusBreakdown": "状态",
|
||||
"allFullyMatched": "全部卖出",
|
||||
"partiallyMatched": "部分卖出",
|
||||
"markets": "个市场",
|
||||
"fetchBuyOrdersFailed": "获取买入订单列表失败",
|
||||
"fetchSellOrdersFailed": "获取卖出订单列表失败",
|
||||
@@ -1194,5 +1262,141 @@
|
||||
"providerQuickNode": "QuickNode",
|
||||
"providerChainstack": "Chainstack",
|
||||
"providerGetBlock": "GetBlock"
|
||||
},
|
||||
"backtest": {
|
||||
"title": "回测",
|
||||
"taskName": "任务名称",
|
||||
"leader": "Leader",
|
||||
"initialBalance": "初始资金",
|
||||
"backtestDays": "回测天数",
|
||||
"status": "状态",
|
||||
"progress": "进度",
|
||||
"startTime": "开始时间",
|
||||
"endTime": "结束时间",
|
||||
"finalBalance": "最终资金",
|
||||
"profitAmount": "收益金额",
|
||||
"profitRate": "收益率",
|
||||
"totalTrades": "总交易数",
|
||||
"buyTrades": "买入笔数",
|
||||
"sellTrades": "卖出笔数",
|
||||
"winTrades": "盈利笔数",
|
||||
"lossTrades": "亏损笔数",
|
||||
"winRate": "胜率",
|
||||
"maxProfit": "最大单笔盈利",
|
||||
"maxLoss": "最大单笔亏损",
|
||||
"maxDrawdown": "最大回撤",
|
||||
"avgHoldingTime": "平均持仓时间",
|
||||
"statusPending": "等待中",
|
||||
"statusRunning": "运行中",
|
||||
"statusCompleted": "已完成",
|
||||
"statusStopped": "已停止",
|
||||
"statusFailed": "失败",
|
||||
"createTask": "创建回测任务",
|
||||
"taskList": "回测任务列表",
|
||||
"taskDetail": "回测任务详情",
|
||||
"tradeRecords": "交易记录",
|
||||
"config": "配置",
|
||||
"statistics": "统计",
|
||||
"chart": "资金曲线",
|
||||
"createSuccess": "创建成功",
|
||||
"createFailed": "创建失败",
|
||||
"deleteSuccess": "删除成功",
|
||||
"deleteFailed": "删除失败",
|
||||
"stop": "停止",
|
||||
"stopSuccess": "停止成功",
|
||||
"stopFailed": "停止失败",
|
||||
"retry": "重试",
|
||||
"retrySuccess": "重试成功",
|
||||
"retryFailed": "重试失败",
|
||||
"retryConfirm": "确定重新运行此回测任务吗?将从断点继续执行,保留已处理的交易记录。",
|
||||
"deleteConfirm": "确定删除此回测任务吗?",
|
||||
"stopConfirm": "确定停止此回测任务吗?",
|
||||
"createCopyTrading": "创建跟单",
|
||||
"createCopyTradingSuccess": "跟单配置创建成功",
|
||||
"noTasks": "暂无回测任务",
|
||||
"noTrades": "暂无交易记录",
|
||||
"fetchTasksFailed": "获取任务列表失败",
|
||||
"fetchTaskDetailFailed": "获取任务详情失败",
|
||||
"fetchTradesFailed": "获取交易记录失败",
|
||||
"balanceChart": "资金变化",
|
||||
"pnlChart": "盈亏变化",
|
||||
"executionTime": "执行时间",
|
||||
"runningDuration": "运行时长",
|
||||
"estimatedRemaining": "预计剩余",
|
||||
"leaderAddress": "Leader 地址",
|
||||
"leaderName": "Leader 名称",
|
||||
"copyMode": "跟单模式",
|
||||
"copyModeRatio": "比例",
|
||||
"copyModeFixed": "固定金额",
|
||||
"copyRatio": "跟单比例",
|
||||
"fixedAmount": "固定金额",
|
||||
"maxOrderSize": "最大单笔订单",
|
||||
"minOrderSize": "最小单笔订单",
|
||||
"maxDailyLoss": "最大每日亏损",
|
||||
"maxDailyOrders": "最大每日订单数",
|
||||
"priceTolerance": "价格容忍度",
|
||||
"delaySeconds": "延迟秒数",
|
||||
"supportSell": "跟单卖出",
|
||||
"minOrderDepth": "最小订单深度",
|
||||
"maxSpread": "最大价差",
|
||||
"minPrice": "最低价格",
|
||||
"maxPrice": "最高价格",
|
||||
"maxPositionValue": "最大仓位金额",
|
||||
"keywordFilterMode": "关键字过滤模式",
|
||||
"keywordFilterModeDisabled": "禁用",
|
||||
"keywordFilterModeWhitelist": "白名单",
|
||||
"keywordFilterModeBlacklist": "黑名单",
|
||||
"keywords": "关键字",
|
||||
"maxMarketEndDate": "市场截止时间限制",
|
||||
"tradeTime": "交易时间",
|
||||
"marketId": "市场ID",
|
||||
"marketTitle": "市场标题",
|
||||
"side": "方向",
|
||||
"sideBuy": "买入",
|
||||
"sideSell": "卖出",
|
||||
"sideSettlement": "结算",
|
||||
"outcome": "结果",
|
||||
"quantity": "数量",
|
||||
"price": "价格",
|
||||
"amount": "金额",
|
||||
"fee": "手续费",
|
||||
"profitLoss": "盈亏",
|
||||
"balanceAfter": "交易后余额",
|
||||
"leaderTradeId": "Leader 交易ID",
|
||||
"loading": "加载中...",
|
||||
"refreshing": "刷新中...",
|
||||
"starting": "启动中...",
|
||||
"stopping": "停止中...",
|
||||
"errorOccurred": "发生错误",
|
||||
"retry": "重试",
|
||||
"taskNameRequired": "请输入任务名称",
|
||||
"leaderRequired": "请选择 Leader",
|
||||
"initialBalanceRequired": "请输入初始资金",
|
||||
"initialBalanceInvalid": "初始资金必须大于 0",
|
||||
"backtestDaysRequired": "请输入回测天数",
|
||||
"backtestDaysInvalid": "回测天数必须在 1-15 之间",
|
||||
"copyRatioRequired": "请输入跟单比例",
|
||||
"copyRatioInvalid": "跟单比例必须在 0.01-10000 之间",
|
||||
"copyRatioPlaceholder": "例如:100 表示 100%(1:1 跟单),默认 100%",
|
||||
"copyRatioTooltip": "跟单比例表示跟单金额相对于 Leader 订单金额的百分比。例如:100% 表示 1:1 跟单,50% 表示半仓跟单,200% 表示双倍跟单",
|
||||
"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": "创建时间",
|
||||
"rerun": "重新测试",
|
||||
"rerunConfirm": "将按当前配置创建新的回测任务,是否继续?",
|
||||
"rerunTaskNamePlaceholder": "新任务名称(留空使用「原名称 (副本)」)",
|
||||
"rerunSuccess": "已创建新回测任务",
|
||||
"rerunFailed": "重新测试失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"edit": "編輯",
|
||||
"viewDetail": "查看詳情",
|
||||
"delete": "刪除",
|
||||
"add": "添加",
|
||||
"search": "搜索",
|
||||
@@ -15,6 +16,10 @@
|
||||
"submit": "提交",
|
||||
"reset": "重置",
|
||||
"close": "關閉",
|
||||
"create": "創建",
|
||||
"copy": "複製",
|
||||
"copySuccess": "複製成功",
|
||||
"copyFailed": "複製失敗",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"actions": "操作",
|
||||
@@ -33,7 +38,10 @@
|
||||
"previous": "上一頁",
|
||||
"next": "下一頁",
|
||||
"page": "頁",
|
||||
"pageOf": "第"
|
||||
"pageOf": "第",
|
||||
"ascending": "升序",
|
||||
"descending": "降序",
|
||||
"day": "天"
|
||||
},
|
||||
"account": {
|
||||
"title": "賬戶管理",
|
||||
@@ -45,6 +53,7 @@
|
||||
"accountId": "賬戶ID",
|
||||
"accountName": "賬戶名稱",
|
||||
"walletAddress": "錢包地址",
|
||||
"walletType": "錢包類型",
|
||||
"balance": "賬戶餘額",
|
||||
"refreshBalance": "刷新餘額",
|
||||
"apiCredentials": "API 憑證配置",
|
||||
@@ -114,6 +123,7 @@
|
||||
"importAccount": "導入賬戶",
|
||||
"accountName": "賬戶名稱",
|
||||
"walletAddress": "錢包地址",
|
||||
"walletType": "錢包類型",
|
||||
"proxyAddress": "代理錢包地址",
|
||||
"apiCredentials": "API 憑證",
|
||||
"balance": "餘額",
|
||||
@@ -170,8 +180,6 @@
|
||||
"accountImport": {
|
||||
"title": "導入賬戶",
|
||||
"back": "返回",
|
||||
"securityTip": "安全提示",
|
||||
"securityTipDesc": "私鑰將存儲在後端數據庫中,請確保數據庫訪問安全。建議使用 HTTPS 連接。",
|
||||
"importMethod": "導入方式",
|
||||
"privateKey": "私鑰",
|
||||
"mnemonic": "助記詞",
|
||||
@@ -205,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 管理",
|
||||
@@ -216,7 +244,7 @@
|
||||
"walletAddress": "錢包地址",
|
||||
"category": "分類",
|
||||
"all": "全部",
|
||||
"copyTradingCount": "跟單關係數",
|
||||
"copyTradingCount": "跟單數",
|
||||
"createdAt": "創建時間",
|
||||
"action": "操作",
|
||||
"add": "添加",
|
||||
@@ -239,6 +267,7 @@
|
||||
"templates": "跟單模板",
|
||||
"copyTradingConfig": "跟單配置",
|
||||
"positions": "倉位管理",
|
||||
"backtest": "回測",
|
||||
"statistics": "統計信息",
|
||||
"announcements": "公告",
|
||||
"users": "用戶管理",
|
||||
@@ -424,15 +453,21 @@
|
||||
"website": "網站",
|
||||
"openWebsite": "打開網頁",
|
||||
"all": "全部",
|
||||
"copyTradingCount": "跟單關係數",
|
||||
"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",
|
||||
@@ -481,6 +516,38 @@
|
||||
"fetchFailed": "獲取 Leader 詳情失敗",
|
||||
"invalidId": "Leader ID 無效"
|
||||
},
|
||||
"leaderDetail": {
|
||||
"title": "Leader 詳情",
|
||||
"loading": "加載中...",
|
||||
"invalidId": "Leader ID 無效",
|
||||
"fetchFailed": "獲取 Leader 詳情失敗",
|
||||
"fetchBalanceFailed": "獲取餘額失敗",
|
||||
"noBalanceData": "暫無餘額數據",
|
||||
"basicInfo": "基本信息",
|
||||
"leaderName": "Leader 名稱",
|
||||
"leaderAddress": "錢包地址",
|
||||
"category": "分類",
|
||||
"copyTradingCount": "跟單數",
|
||||
"remark": "備註",
|
||||
"createdAt": "創建時間",
|
||||
"updatedAt": "更新時間",
|
||||
"website": "網站",
|
||||
"openWebsite": "打開網頁",
|
||||
"balanceInfo": "餘額信息",
|
||||
"refresh": "刷新",
|
||||
"availableBalance": "可用餘額",
|
||||
"positionBalance": "倉位資產",
|
||||
"totalBalance": "總餘額",
|
||||
"positions": "持倉列表",
|
||||
"noPositions": "暫無持倉",
|
||||
"market": "市場",
|
||||
"marketId": "市場ID",
|
||||
"side": "方向",
|
||||
"quantity": "數量",
|
||||
"avgPrice": "均價",
|
||||
"currentValue": "當前價值",
|
||||
"pnl": "盈虧"
|
||||
},
|
||||
"userList": {
|
||||
"title": "用戶管理",
|
||||
"username": "用戶名",
|
||||
@@ -686,9 +753,6 @@
|
||||
"maxPositionValue": "最大倉位金額 (USDC)",
|
||||
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
|
||||
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
|
||||
"maxPositionCount": "最大倉位數量",
|
||||
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
|
||||
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
|
||||
"supportSell": "跟單賣出",
|
||||
"supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。",
|
||||
"pushFilteredOrders": "推送已過濾訂單",
|
||||
@@ -760,9 +824,6 @@
|
||||
"maxPositionValue": "最大倉位金額 (USDC)",
|
||||
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
|
||||
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
|
||||
"maxPositionCount": "最大倉位數量",
|
||||
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
|
||||
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
|
||||
"keywordFilter": "關鍵字過濾",
|
||||
"keywordFilterMode": "過濾模式",
|
||||
"keywordFilterModeTooltip": "選擇關鍵字過濾模式。白名單:只跟單包含關鍵字的市場;黑名單:不跟單包含關鍵字的市場;不啟用:不進行關鍵字過濾。關鍵字匹配不區分大小寫。",
|
||||
@@ -805,10 +866,16 @@
|
||||
"fetchLeaderFailed": "獲取 Leader 列表失敗",
|
||||
"fetchTemplateFailed": "獲取模板列表失敗",
|
||||
"templateName": "模板名稱",
|
||||
"noAccounts": "暫無賬戶",
|
||||
"importAccount": "導入賬戶",
|
||||
"noAccounts": "暫無帳戶",
|
||||
"importAccount": "導入帳戶",
|
||||
"noLeaders": "暫無 Leader",
|
||||
"addLeader": "添加 Leader"
|
||||
"addLeader": "添加 Leader",
|
||||
"leaderAssetInfo": "Leader 資產資訊",
|
||||
"loadingAssetInfo": "加載資產資訊中...",
|
||||
"totalAsset": "總資產",
|
||||
"availableBalance": "可用餘額",
|
||||
"positionAsset": "倉位資產",
|
||||
"fetchAssetInfoFailed": "獲取資產資訊失敗"
|
||||
},
|
||||
"copyTradingEdit": {
|
||||
"title": "編輯跟單配置",
|
||||
@@ -867,9 +934,6 @@
|
||||
"maxPositionValue": "最大倉位金額 (USDC)",
|
||||
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
|
||||
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
|
||||
"maxPositionCount": "最大倉位數量",
|
||||
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
|
||||
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
|
||||
"keywordFilter": "關鍵字過濾",
|
||||
"keywordFilterMode": "過濾模式",
|
||||
"keywordFilterModeTooltip": "選擇關鍵字過濾模式。白名單:只跟單包含關鍵字的市場;黑名單:不跟單包含關鍵字的市場;不啟用:不進行關鍵字過濾。關鍵字匹配不區分大小寫。",
|
||||
@@ -934,11 +998,13 @@
|
||||
"orderbookEmpty": "訂單簿為空",
|
||||
"priceRange": "價格區間不符",
|
||||
"maxPositionValue": "超過最大倉位金額",
|
||||
"maxPositionCount": "超過最大倉位數量",
|
||||
"marketEndDate": "市場截止時間超出限制",
|
||||
"keywordFilter": "關鍵字過濾",
|
||||
"unknown": "未知原因"
|
||||
},
|
||||
"noData": "暫無已過濾訂單"
|
||||
"orderDetails": "訂單詳情",
|
||||
"noData": "暫無已過濾訂單",
|
||||
"noFilteredOrders": "暫無已過濾訂單"
|
||||
},
|
||||
"copyTradingList": {
|
||||
"title": "跟單配置管理",
|
||||
@@ -1018,7 +1084,7 @@
|
||||
"telegramConfig": {
|
||||
"title": "Telegram 配置說明",
|
||||
"step1": "通過 <strong>@BotFather</strong> 創建 Telegram 機器人,獲取 Bot Token",
|
||||
"step2": "填寫 Bot Token 後,點擊\"獲取 Chat ID\"按鈕自動獲取(需要先向機器人發送消息)",
|
||||
"step2": "填寫 Bot Token 後,點擊\"獲取 Chat ID\"按鈕自動獲取(需要先向機器人發送消息)",
|
||||
"step3": "或通過 <strong>@userinfobot</strong> 手動獲取 Chat ID",
|
||||
"step4": "支持配置多個 Chat ID(用逗號分隔),所有配置的用戶都會收到通知",
|
||||
"step5": "訂單成功或失敗時會自動發送 Telegram 消息",
|
||||
@@ -1080,8 +1146,8 @@
|
||||
"sellStatus": "賣出狀態",
|
||||
"status": "狀態",
|
||||
"statusFilled": "已完成",
|
||||
"statusPartiallySold": "部分成交",
|
||||
"statusFullySold": "全部成交",
|
||||
"statusPartiallySold": "部分賣出",
|
||||
"statusFullySold": "全部賣出",
|
||||
"realizedPnl": "已實現盈虧",
|
||||
"createdAt": "創建時間",
|
||||
"matchedAt": "匹配時間",
|
||||
@@ -1099,6 +1165,7 @@
|
||||
"price": "價格",
|
||||
"amount": "金額",
|
||||
"filterMarketId": "篩選市場ID",
|
||||
"filterMarketTitle": "篩選市場標題",
|
||||
"filterSide": "篩選方向",
|
||||
"filterStatus": "篩選狀態",
|
||||
"filterSellOrderId": "篩選賣出訂單ID",
|
||||
@@ -1109,12 +1176,15 @@
|
||||
"groupByMarket": "按市場分組",
|
||||
"expandAll": "展開全部",
|
||||
"collapseAll": "折疊全部",
|
||||
"allFullyMatched": "全部成交",
|
||||
"partiallyMatched": "部分成交",
|
||||
"allFullySold": "全部賣出",
|
||||
"notSold": "未賣出",
|
||||
"partiallySold": "部分賣出",
|
||||
"orderCount": "訂單數",
|
||||
"totalAmount": "總金額",
|
||||
"totalPnl": "總盈虧",
|
||||
"statusBreakdown": "狀態",
|
||||
"allFullyMatched": "全部賣出",
|
||||
"partiallyMatched": "部分賣出",
|
||||
"totalPnl": "總盈虧",
|
||||
"markets": "個市場",
|
||||
"fetchBuyOrdersFailed": "獲取買入訂單列表失敗",
|
||||
"fetchSellOrdersFailed": "獲取賣出訂單列表失敗",
|
||||
@@ -1126,7 +1196,6 @@
|
||||
"totalMatchedOrders": "總匹配訂單數",
|
||||
"totalBuyAmount": "總買入金額",
|
||||
"totalSellAmount": "總賣出金額",
|
||||
"totalPnl": "總盈虧",
|
||||
"totalRealizedPnl": "總已實現盈虧",
|
||||
"totalUnrealizedPnl": "總未實現盈虧",
|
||||
"winRate": "勝率",
|
||||
@@ -1194,5 +1263,141 @@
|
||||
"providerQuickNode": "QuickNode",
|
||||
"providerChainstack": "Chainstack",
|
||||
"providerGetBlock": "GetBlock"
|
||||
},
|
||||
"backtest": {
|
||||
"title": "回測",
|
||||
"taskName": "任務名稱",
|
||||
"leader": "Leader",
|
||||
"initialBalance": "初始資金",
|
||||
"backtestDays": "回測天數",
|
||||
"status": "狀態",
|
||||
"progress": "進度",
|
||||
"startTime": "開始時間",
|
||||
"endTime": "結束時間",
|
||||
"finalBalance": "最終資金",
|
||||
"profitAmount": "收益金額",
|
||||
"profitRate": "收益率",
|
||||
"totalTrades": "總交易數",
|
||||
"buyTrades": "買入筆數",
|
||||
"sellTrades": "賣出筆數",
|
||||
"winTrades": "盈利筆數",
|
||||
"lossTrades": "虧損筆數",
|
||||
"winRate": "勝率",
|
||||
"maxProfit": "最大單筆盈利",
|
||||
"maxLoss": "最大單筆虧損",
|
||||
"maxDrawdown": "最大回撤",
|
||||
"avgHoldingTime": "平均持倉時間",
|
||||
"statusPending": "等待中",
|
||||
"statusRunning": "運行中",
|
||||
"statusCompleted": "已完成",
|
||||
"statusStopped": "已停止",
|
||||
"statusFailed": "失敗",
|
||||
"createTask": "創建回測任務",
|
||||
"taskList": "回測任務列表",
|
||||
"taskDetail": "回測任務詳情",
|
||||
"tradeRecords": "交易記錄",
|
||||
"config": "配置",
|
||||
"statistics": "統計",
|
||||
"chart": "資金曲線",
|
||||
"createSuccess": "創建成功",
|
||||
"createFailed": "創建失敗",
|
||||
"deleteSuccess": "刪除成功",
|
||||
"deleteFailed": "刪除失敗",
|
||||
"stop": "停止",
|
||||
"stopSuccess": "停止成功",
|
||||
"stopFailed": "停止失敗",
|
||||
"retry": "重試",
|
||||
"retrySuccess": "重試成功",
|
||||
"retryFailed": "重試失敗",
|
||||
"retryConfirm": "確定重新運行此回測任務嗎?將從斷點繼續執行,保留已處理的交易記錄。",
|
||||
"deleteConfirm": "確定刪除此回測任務嗎?",
|
||||
"stopConfirm": "確定停止此回測任務嗎?",
|
||||
"createCopyTrading": "創建跟單",
|
||||
"createCopyTradingSuccess": "跟單配置創建成功",
|
||||
"noTasks": "暫無回測任務",
|
||||
"noTrades": "暫無交易記錄",
|
||||
"fetchTasksFailed": "獲取任務列表失敗",
|
||||
"fetchTaskDetailFailed": "獲取任務詳情失敗",
|
||||
"fetchTradesFailed": "獲取交易記錄失敗",
|
||||
"balanceChart": "資金變化",
|
||||
"pnlChart": "盈虧變化",
|
||||
"executionTime": "執行時間",
|
||||
"runningDuration": "運行時長",
|
||||
"estimatedRemaining": "預計剩餘",
|
||||
"leaderAddress": "Leader 地址",
|
||||
"leaderName": "Leader 名稱",
|
||||
"copyMode": "跟單模式",
|
||||
"copyModeRatio": "比例",
|
||||
"copyModeFixed": "固定金額",
|
||||
"copyRatio": "跟單比例",
|
||||
"fixedAmount": "固定金額",
|
||||
"maxOrderSize": "最大單筆訂單",
|
||||
"minOrderSize": "最小單筆訂單",
|
||||
"maxDailyLoss": "最大每日虧損",
|
||||
"maxDailyOrders": "最大每日訂單數",
|
||||
"priceTolerance": "價格容忍度",
|
||||
"delaySeconds": "延遲秒數",
|
||||
"supportSell": "跟單賣出",
|
||||
"minOrderDepth": "最小訂單深度",
|
||||
"maxSpread": "最大價差",
|
||||
"minPrice": "最低價格",
|
||||
"maxPrice": "最高價格",
|
||||
"maxPositionValue": "最大倉位金額",
|
||||
"keywordFilterMode": "關鍵字過濾模式",
|
||||
"keywordFilterModeDisabled": "禁用",
|
||||
"keywordFilterModeWhitelist": "白名單",
|
||||
"keywordFilterModeBlacklist": "黑名單",
|
||||
"keywords": "關鍵字",
|
||||
"maxMarketEndDate": "市場截止時間限制",
|
||||
"tradeTime": "交易時間",
|
||||
"marketId": "市場ID",
|
||||
"marketTitle": "市場標題",
|
||||
"side": "方向",
|
||||
"sideBuy": "買入",
|
||||
"sideSell": "賣出",
|
||||
"sideSettlement": "結算",
|
||||
"outcome": "結果",
|
||||
"quantity": "數量",
|
||||
"price": "價格",
|
||||
"amount": "金額",
|
||||
"fee": "手續費",
|
||||
"profitLoss": "盈虧",
|
||||
"balanceAfter": "交易後餘額",
|
||||
"leaderTradeId": "Leader 交易ID",
|
||||
"loading": "加載中...",
|
||||
"refreshing": "刷新中...",
|
||||
"starting": "啟動中...",
|
||||
"stopping": "停止中...",
|
||||
"errorOccurred": "發生錯誤",
|
||||
"retry": "重試",
|
||||
"taskNameRequired": "請輸入任務名稱",
|
||||
"leaderRequired": "請選擇 Leader",
|
||||
"initialBalanceRequired": "請輸入初始資金",
|
||||
"initialBalanceInvalid": "初始資金必須大於 0",
|
||||
"backtestDaysRequired": "請輸入回測天數",
|
||||
"backtestDaysInvalid": "回測天數必須在 1-15 之間",
|
||||
"copyRatioRequired": "請輸入跟單比例",
|
||||
"copyRatioInvalid": "跟單比例必須在 0.01-10000 之間",
|
||||
"copyRatioPlaceholder": "例如:100 表示 100%(1:1 跟單),預設 100%",
|
||||
"copyRatioTooltip": "跟單比例表示跟單金額相對於 Leader 訂單金額的百分比。例如:100% 表示 1:1 跟單,50% 表示半倉跟單,200% 表示雙倍跟單",
|
||||
"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": "創建時間",
|
||||
"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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user