feat(account): 按代理地址去重、默认账户名、前端重复提示与 DB 唯一约束
后端: - 导入去重改为按 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 <cursoragent@cursor.com>
This commit is contained in:
+10
-7
@@ -89,14 +89,17 @@ class AccountController(
|
|||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
logger.error("导入账户失败: ${e.message}", e)
|
logger.error("导入账户失败: ${e.message}", e)
|
||||||
when (e) {
|
when (e) {
|
||||||
is IllegalArgumentException -> ResponseEntity.ok(
|
is IllegalArgumentException -> if (e.message == "ACCOUNT_ALREADY_EXISTS") {
|
||||||
ApiResponse.error(
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.ACCOUNT_ALREADY_EXISTS, messageSource = messageSource))
|
||||||
ErrorCode.PARAM_ERROR,
|
} else {
|
||||||
e.message,
|
ResponseEntity.ok(
|
||||||
messageSource
|
ApiResponse.error(
|
||||||
|
ErrorCode.PARAM_ERROR,
|
||||||
|
e.message,
|
||||||
|
messageSource
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message, messageSource))
|
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message, messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ data class Account(
|
|||||||
@Column(name = "private_key", nullable = false, length = 500)
|
@Column(name = "private_key", nullable = false, length = 500)
|
||||||
val privateKey: String, // 私钥(AES 加密存储)
|
val privateKey: String, // 私钥(AES 加密存储)
|
||||||
|
|
||||||
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
|
@Column(name = "wallet_address", nullable = false, length = 42)
|
||||||
val walletAddress: String, // 钱包地址(从私钥推导)
|
val walletAddress: String, // 钱包地址(从私钥推导),同一 EOA 可有多个账户(不同代理类型)
|
||||||
|
|
||||||
@Column(name = "proxy_address", nullable = false, length = 42)
|
@Column(name = "proxy_address", unique = true, nullable = false, length = 42)
|
||||||
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
|
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须),唯一
|
||||||
|
|
||||||
@Column(name = "api_key", length = 500)
|
@Column(name = "api_key", length = 500)
|
||||||
val apiKey: String? = null, // Polymarket API Key(可选,明文存储)
|
val apiKey: String? = null, // Polymarket API Key(可选,明文存储)
|
||||||
|
|||||||
@@ -29,5 +29,10 @@ interface AccountRepository : JpaRepository<Account, Long> {
|
|||||||
* 检查钱包地址是否存在
|
* 检查钱包地址是否存在
|
||||||
*/
|
*/
|
||||||
fun existsByWalletAddress(walletAddress: String): Boolean
|
fun existsByWalletAddress(walletAddress: String): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查代理地址是否存在
|
||||||
|
*/
|
||||||
|
fun existsByProxyAddress(proxyAddress: String): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-14
@@ -68,11 +68,6 @@ class AccountService(
|
|||||||
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
|
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 检查地址是否已存在
|
|
||||||
if (accountRepository.existsByWalletAddress(request.walletAddress)) {
|
|
||||||
return Result.failure(IllegalArgumentException("该钱包地址已存在"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 验证私钥和地址的对应关系
|
// 3. 验证私钥和地址的对应关系
|
||||||
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
|
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
|
||||||
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
|
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
|
||||||
@@ -123,25 +118,31 @@ class AccountService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6. 按代理地址去重:该代理地址已存在则不允许重复导入
|
||||||
|
if (accountRepository.existsByProxyAddress(proxyAddress)) {
|
||||||
|
return Result.failure(IllegalArgumentException("ACCOUNT_ALREADY_EXISTS"))
|
||||||
|
}
|
||||||
|
|
||||||
// 7. 加密敏感信息
|
// 7. 加密敏感信息
|
||||||
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
|
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
|
||||||
val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) }
|
val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) }
|
||||||
val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) }
|
val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) }
|
||||||
|
|
||||||
// 8. 生成账户名称(如果未提供,使用钱包地址后四位)
|
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
|
||||||
val accountName = if (request.accountName.isNullOrBlank()) {
|
val accountName = if (request.accountName.isNullOrBlank()) {
|
||||||
val walletAddress = request.walletAddress.trim()
|
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
|
||||||
// 取地址后四位(去掉 0x 前缀后取后四位)
|
val typeLabel = walletTypeEnum.name.uppercase()
|
||||||
val addressWithoutPrefix = if (walletAddress.startsWith("0x") || walletAddress.startsWith("0X")) {
|
val proxyWithoutPrefix = if (proxyAddress.startsWith("0x") || proxyAddress.startsWith("0X")) {
|
||||||
walletAddress.substring(2)
|
proxyAddress.substring(2)
|
||||||
} else {
|
} else {
|
||||||
walletAddress
|
proxyAddress
|
||||||
}
|
}
|
||||||
if (addressWithoutPrefix.length >= 4) {
|
val suffix = if (proxyWithoutPrefix.length >= 4) {
|
||||||
addressWithoutPrefix.substring(addressWithoutPrefix.length - 4).uppercase()
|
proxyWithoutPrefix.substring(proxyWithoutPrefix.length - 4).uppercase()
|
||||||
} else {
|
} else {
|
||||||
addressWithoutPrefix.uppercase()
|
proxyWithoutPrefix.uppercase()
|
||||||
}
|
}
|
||||||
|
"$typeLabel-$suffix"
|
||||||
} else {
|
} else {
|
||||||
request.accountName.trim()
|
request.accountName.trim()
|
||||||
}
|
}
|
||||||
|
|||||||
+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;
|
||||||
@@ -267,7 +267,10 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Promise.resolve()
|
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)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,8 +60,10 @@ export const useAccountStore = create<AccountStore>((set, get) => ({
|
|||||||
if (response.data.code === 0) {
|
if (response.data.code === 0) {
|
||||||
await get().fetchAccounts()
|
await get().fetchAccounts()
|
||||||
} else {
|
} else {
|
||||||
|
const err = new Error(response.data.msg || '导入账户失败')
|
||||||
|
;(err as Error & { code?: number }).code = response.data.code
|
||||||
set({ error: response.data.msg || '导入账户失败', loading: false })
|
set({ error: response.data.msg || '导入账户失败', loading: false })
|
||||||
throw new Error(response.data.msg || '导入账户失败')
|
throw err
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
set({ error: error.message || '导入账户失败', loading: false })
|
set({ error: error.message || '导入账户失败', loading: false })
|
||||||
|
|||||||
Reference in New Issue
Block a user