From f1ec0a330b5ee5bfa6435a50bb99fb40008e0c79 Mon Sep 17 00:00:00 2001 From: WrBug Date: Sat, 14 Feb 2026 01:40:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(account):=20=E6=8C=89=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E5=8E=BB=E9=87=8D=E3=80=81=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E5=90=8D=E3=80=81=E5=89=8D=E7=AB=AF=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E6=8F=90=E7=A4=BA=E4=B8=8E=20DB=20=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 导入去重改为按 proxy_address(existsByProxyAddress),重复时返回 ACCOUNT_ALREADY_EXISTS(4601) - 未填账户名时默认生成 SAFE/MAGIC-代理地址后4位(无中括号) - Controller 识别 ACCOUNT_ALREADY_EXISTS 并返回对应错误码 前端: - 导入失败时 message.error 提示;code=4601 时使用 accountImport.duplicateAccount 多语言 - store 抛出错误时附带 response.data.code 供表单判断 数据库 V33: - 移除 wallet_address 唯一约束,新增 proxy_address 唯一约束 - 已存在账户的 wallet_type 统一更新为 safe 实体: - Account.walletAddress 取消 unique,Account.proxyAddress 设为 unique Co-authored-by: Cursor --- .../controller/accounts/AccountController.kt | 17 +++++---- .../com/wrbug/polymarketbot/entity/Account.kt | 8 ++-- .../repository/AccountRepository.kt | 5 +++ .../service/accounts/AccountService.kt | 29 +++++++------- ...traint_proxy_address_instead_of_wallet.sql | 38 +++++++++++++++++++ frontend/src/components/AccountImportForm.tsx | 5 ++- frontend/src/store/accountStore.ts | 4 +- 7 files changed, 79 insertions(+), 27 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V33__unique_constraint_proxy_address_instead_of_wallet.sql diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt index 07e28bd..b91212d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt @@ -89,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)) } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt index d0cd8d1..a6ba8e3 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt @@ -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(可选,明文存储) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/AccountRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/AccountRepository.kt index 2a03aa5..ccfcd54 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/AccountRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/AccountRepository.kt @@ -29,5 +29,10 @@ interface AccountRepository : JpaRepository { * 检查钱包地址是否存在 */ fun existsByWalletAddress(walletAddress: String): Boolean + + /** + * 检查代理地址是否存在 + */ + fun existsByProxyAddress(proxyAddress: String): Boolean } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt index 54a83ec..7368eac 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt @@ -68,11 +68,6 @@ class AccountService( return Result.failure(IllegalArgumentException("无效的钱包地址格式")) } - // 2. 检查地址是否已存在 - if (accountRepository.existsByWalletAddress(request.walletAddress)) { - return Result.failure(IllegalArgumentException("该钱包地址已存在")) - } - // 3. 验证私钥和地址的对应关系 // 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证 // 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证 @@ -123,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() } diff --git a/backend/src/main/resources/db/migration/V33__unique_constraint_proxy_address_instead_of_wallet.sql b/backend/src/main/resources/db/migration/V33__unique_constraint_proxy_address_instead_of_wallet.sql new file mode 100644 index 0000000..ce4e27d --- /dev/null +++ b/backend/src/main/resources/db/migration/V33__unique_constraint_proxy_address_instead_of_wallet.sql @@ -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; diff --git a/frontend/src/components/AccountImportForm.tsx b/frontend/src/components/AccountImportForm.tsx index 4b08c4b..04fade2 100644 --- a/frontend/src/components/AccountImportForm.tsx +++ b/frontend/src/components/AccountImportForm.tsx @@ -267,7 +267,10 @@ const AccountImportForm: React.FC = ({ } 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) } } diff --git a/frontend/src/store/accountStore.ts b/frontend/src/store/accountStore.ts index e28f573..8df349f 100644 --- a/frontend/src/store/accountStore.ts +++ b/frontend/src/store/accountStore.ts @@ -60,8 +60,10 @@ export const useAccountStore = create((set, get) => ({ if (response.data.code === 0) { await get().fetchAccounts() } else { + const err = new Error(response.data.msg || '导入账户失败') + ;(err as Error & { code?: number }).code = response.data.code set({ error: response.data.msg || '导入账户失败', loading: false }) - throw new Error(response.data.msg || '导入账户失败') + throw err } } catch (error: any) { set({ error: error.message || '导入账户失败', loading: false })