From 2d91552ea49e3cd9707208b779da682677bda2a9 Mon Sep 17 00:00:00 2001 From: mediabot-pt <295750538+mediabot-pt@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:30:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20API=20Key=20=E9=9D=99=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=20+=20=E5=A4=9A=E9=82=AE=E7=AE=B1=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心改动: - V3 迁移: accounts 加 api_key/api_secret + account_emails 多邮箱关联表 - HMAC 验签改用 api_secret 替代密码哈希(改密码不影响集成) - findByAnyEmail 支持主邮箱 + 关联邮箱查找 - AdminController: API Key 生成/重置 + 邮箱绑定/解绑/列表 - 前端 Users.vue: API Key 列 + 生成按钮 + Secret 复制弹窗 - 现有邮箱自动迁移到 account_emails 表 认证流程: 旧: X-Email → account.email → passwordHash 作 HMAC key 新: X-Email → account_emails ∪ account.email → api_secret 作 HMAC key --- .../par/api/controller/AdminController.java | 68 ++++++++++++++ .../java/com/par/core/dto/AccountDTO.java | 3 + .../java/com/par/core/entity/Account.java | 6 ++ .../com/par/core/entity/AccountEmail.java | 29 ++++++ .../core/interceptor/HmacAuthInterceptor.java | 14 ++- .../par/core/mapper/AccountEmailMapper.java | 22 +++++ .../com/par/core/service/AccountService.java | 28 ++++++ .../core/service/impl/AccountServiceImpl.java | 94 +++++++++++++++++++ .../db/migration/V3__api_key_multi_email.sql | 29 ++++++ web/src/api/index.js | 3 +- web/src/views/Users.vue | 66 +++++++++++-- 11 files changed, 349 insertions(+), 13 deletions(-) create mode 100644 par-core/src/main/java/com/par/core/entity/AccountEmail.java create mode 100644 par-core/src/main/java/com/par/core/mapper/AccountEmailMapper.java create mode 100644 par-core/src/main/resources/db/migration/V3__api_key_multi_email.sql diff --git a/par-api/src/main/java/com/par/api/controller/AdminController.java b/par-api/src/main/java/com/par/api/controller/AdminController.java index 4e834bd..77b6abd 100644 --- a/par-api/src/main/java/com/par/api/controller/AdminController.java +++ b/par-api/src/main/java/com/par/api/controller/AdminController.java @@ -18,6 +18,8 @@ import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import java.util.List; /** * 管理员控制器 @@ -153,6 +155,72 @@ public class AdminController { return ApiResponse.success(); } + /** + * 生成/重置 API Key + */ + @PostMapping("/accounts/{accountId}/api-key") + public ApiResponse> generateApiKey( + @PathVariable Long accountId, + HttpServletRequest request) { + checkAdmin(request); + + var account = accountMapper.selectById(accountId); + if (account == null) { + return ApiResponse.error(404, "Account not found"); + } + + String[] keys = accountService.generateApiCredentials(accountId); + Map result = new java.util.LinkedHashMap<>(); + result.put("apiKey", keys[0]); + result.put("apiSecret", keys[1]); + // secret 只在生成时返回一次,前端需要立即保存 + log.info("API key generated for account: id={}", accountId); + return ApiResponse.success(result); + } + + /** + * 绑定额外邮箱 + */ + @PostMapping("/accounts/{accountId}/emails") + public ApiResponse bindEmail( + @PathVariable Long accountId, + @RequestBody Map payload, + HttpServletRequest request) { + checkAdmin(request); + String email = payload.get("email"); + if (email == null || email.isBlank()) { + return ApiResponse.error(400, "email is required"); + } + accountService.bindEmail(accountId, email); + log.info("Email bound to account: accountId={}, email={}", accountId, email); + return ApiResponse.success(); + } + + /** + * 解绑邮箱 + */ + @DeleteMapping("/accounts/{accountId}/emails/{emailId}") + public ApiResponse unbindEmail( + @PathVariable Long accountId, + @PathVariable Long emailId, + HttpServletRequest request) { + checkAdmin(request); + accountService.unbindEmail(emailId); + log.info("Email unbound: accountId={}, emailId={}", accountId, emailId); + return ApiResponse.success(); + } + + /** + * 获取账户的绑定邮箱列表 + */ + @GetMapping("/accounts/{accountId}/emails") + public ApiResponse> listEmails( + @PathVariable Long accountId, + HttpServletRequest request) { + checkAdmin(request); + return ApiResponse.success(accountService.listEmails(accountId)); + } + private void checkAdmin(HttpServletRequest request) { TrustLevel level = (TrustLevel) request.getAttribute("trustLevel"); if (level == null || level != TrustLevel.ADMIN) { diff --git a/par-core/src/main/java/com/par/core/dto/AccountDTO.java b/par-core/src/main/java/com/par/core/dto/AccountDTO.java index 85a2c7b..8c8d67e 100644 --- a/par-core/src/main/java/com/par/core/dto/AccountDTO.java +++ b/par-core/src/main/java/com/par/core/dto/AccountDTO.java @@ -22,4 +22,7 @@ public class AccountDTO { /** 登录 token(仅登录接口返回) */ private String token; + + /** API Key(管理员可见,用于外部集成) */ + private String apiKey; } diff --git a/par-core/src/main/java/com/par/core/entity/Account.java b/par-core/src/main/java/com/par/core/entity/Account.java index a60ca93..5c8577e 100644 --- a/par-core/src/main/java/com/par/core/entity/Account.java +++ b/par-core/src/main/java/com/par/core/entity/Account.java @@ -22,6 +22,12 @@ public class Account { @TableField("password_hash") private String passwordHash; + @TableField("api_key") + private String apiKey; + + @TableField("api_secret") + private String apiSecret; + @TableField("display_name") private String displayName; diff --git a/par-core/src/main/java/com/par/core/entity/AccountEmail.java b/par-core/src/main/java/com/par/core/entity/AccountEmail.java new file mode 100644 index 0000000..0e785ef --- /dev/null +++ b/par-core/src/main/java/com/par/core/entity/AccountEmail.java @@ -0,0 +1,29 @@ +package com.par.core.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 账户多邮箱关联实体 + */ +@Data +@TableName("account_emails") +public class AccountEmail { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("account_id") + private Long accountId; + + @TableField("email") + private String email; + + @TableField("verified") + private Boolean verified; + + @TableField(value = "created_at", fill = FieldFill.INSERT) + private LocalDateTime createdAt; +} diff --git a/par-core/src/main/java/com/par/core/interceptor/HmacAuthInterceptor.java b/par-core/src/main/java/com/par/core/interceptor/HmacAuthInterceptor.java index 52684d9..1078f50 100644 --- a/par-core/src/main/java/com/par/core/interceptor/HmacAuthInterceptor.java +++ b/par-core/src/main/java/com/par/core/interceptor/HmacAuthInterceptor.java @@ -87,13 +87,19 @@ public class HmacAuthInterceptor implements HandlerInterceptor { return false; } - // 查找账户 - Account account = accountService.findByEmail(email); + // 查找账户(支持主邮箱和关联邮箱) + Account account = accountService.findByAnyEmail(email); if (account == null || !Boolean.TRUE.equals(account.getIsActive())) { writeError(response, 401, "Invalid credentials"); return false; } + // 检查是否有 API Secret + if (account.getApiSecret() == null || account.getApiSecret().isBlank()) { + writeError(response, 401, "API credentials not configured for this account"); + return false; + } + // 计算 body hash String body = readBody(request); String bodyHash = HmacUtil.sha256(body); @@ -106,8 +112,8 @@ public class HmacAuthInterceptor implements HandlerInterceptor { bodyHash ); - // 使用密码哈希作为 HMAC 密钥 - boolean valid = HmacUtil.verify(account.getPasswordHash(), signContent, signature); + // 使用 api_secret 作为 HMAC 密钥 + boolean valid = HmacUtil.verify(account.getApiSecret(), signContent, signature); if (!valid) { writeError(response, 401, "Invalid signature"); return false; diff --git a/par-core/src/main/java/com/par/core/mapper/AccountEmailMapper.java b/par-core/src/main/java/com/par/core/mapper/AccountEmailMapper.java new file mode 100644 index 0000000..30a17b1 --- /dev/null +++ b/par-core/src/main/java/com/par/core/mapper/AccountEmailMapper.java @@ -0,0 +1,22 @@ +package com.par.core.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.par.core.entity.AccountEmail; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 多邮箱关联 Mapper + */ +@Mapper +public interface AccountEmailMapper extends BaseMapper { + + @Select("SELECT * FROM account_emails WHERE email = #{email} AND verified = 1 LIMIT 1") + AccountEmail selectByEmail(@Param("email") String email); + + @Select("SELECT * FROM account_emails WHERE account_id = #{accountId}") + List selectByAccountId(@Param("accountId") Long accountId); +} diff --git a/par-core/src/main/java/com/par/core/service/AccountService.java b/par-core/src/main/java/com/par/core/service/AccountService.java index 9b122ee..885866e 100644 --- a/par-core/src/main/java/com/par/core/service/AccountService.java +++ b/par-core/src/main/java/com/par/core/service/AccountService.java @@ -3,6 +3,8 @@ package com.par.core.service; import com.par.core.dto.AccountDTO; import com.par.core.dto.LoginRequest; import com.par.core.dto.RegisterRequest; +import java.util.List; + import com.par.core.entity.Account; /** @@ -40,4 +42,30 @@ public interface AccountService { * 转换为 DTO(脱敏) */ AccountDTO toDTO(Account account); + + /** + * 根据任意绑定邮箱查找账户 + */ + Account findByAnyEmail(String email); + + /** + * 生成/重置 API Key 密钥对 + * @return [apiKey, apiSecret] + */ + String[] generateApiCredentials(Long accountId); + + /** + * 绑定额外邮箱 + */ + void bindEmail(Long accountId, String email); + + /** + * 解绑邮箱 + */ + void unbindEmail(Long emailId); + + /** + * 获取账户的所有绑定邮箱 + */ + List listEmails(Long accountId); } diff --git a/par-core/src/main/java/com/par/core/service/impl/AccountServiceImpl.java b/par-core/src/main/java/com/par/core/service/impl/AccountServiceImpl.java index 46200bb..9c65d66 100644 --- a/par-core/src/main/java/com/par/core/service/impl/AccountServiceImpl.java +++ b/par-core/src/main/java/com/par/core/service/impl/AccountServiceImpl.java @@ -6,8 +6,10 @@ import com.par.core.dto.AccountDTO; import com.par.core.dto.LoginRequest; import com.par.core.dto.RegisterRequest; import com.par.core.entity.Account; +import com.par.core.entity.AccountEmail; import com.par.core.entity.AnonymousStat; import com.par.core.enums.TrustLevel; +import com.par.core.mapper.AccountEmailMapper; import com.par.core.mapper.AccountMapper; import com.par.core.mapper.AnonymousStatMapper; import com.par.core.service.AccountService; @@ -16,6 +18,12 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + /** * 账户服务实现 */ @@ -26,6 +34,9 @@ public class AccountServiceImpl implements AccountService { private final AccountMapper accountMapper; private final AnonymousStatMapper anonymousStatMapper; + private final AccountEmailMapper accountEmailMapper; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); @Override @Transactional @@ -89,9 +100,92 @@ public class AccountServiceImpl implements AccountService { dto.setTrustLevel(account.getTrustLevel()); dto.setIsActive(account.getIsActive()); dto.setCreatedAt(account.getCreatedAt()); + dto.setApiKey(account.getApiKey()); return dto; } + @Override + public Account findByAnyEmail(String email) { + email = email.toLowerCase().trim(); + // 先查主邮箱 + Account account = accountMapper.selectByEmail(email); + if (account != null) return account; + // 再查关联邮箱 + AccountEmail ae = accountEmailMapper.selectByEmail(email); + if (ae != null) return accountMapper.selectById(ae.getAccountId()); + return null; + } + + @Override + @Transactional + public String[] generateApiCredentials(Long accountId) { + Account account = accountMapper.selectById(accountId); + if (account == null) throw new IllegalArgumentException("Account not found"); + + byte[] keyBytes = new byte[12]; + SECURE_RANDOM.nextBytes(keyBytes); + String apiKey = "par_" + bytesToHex(keyBytes); + + byte[] secretBytes = new byte[32]; + SECURE_RANDOM.nextBytes(secretBytes); + String apiSecret = Base64.getUrlEncoder().withoutPadding().encodeToString(secretBytes); + + account.setApiKey(apiKey); + account.setApiSecret(apiSecret); + accountMapper.updateById(account); + + return new String[]{apiKey, apiSecret}; + } + + @Override + @Transactional + public void bindEmail(Long accountId, String email) { + email = email.toLowerCase().trim(); + AccountEmail existing = accountEmailMapper.selectByEmail(email); + if (existing != null) { + throw new IllegalArgumentException("该邮箱已被绑定: " + email); + } + AccountEmail ae = new AccountEmail(); + ae.setAccountId(accountId); + ae.setEmail(email); + ae.setVerified(true); + accountEmailMapper.insert(ae); + } + + @Override + @Transactional + public void unbindEmail(Long emailId) { + AccountEmail ae = accountEmailMapper.selectById(emailId); + if (ae == null) throw new IllegalArgumentException("Email binding not found"); + + // 检查是否是唯一邮箱:主邮箱 + 至少保留一个关联邮箱 + Account account = accountMapper.selectById(ae.getAccountId()); + long emailCount = accountEmailMapper.selectByAccountId(ae.getAccountId()).size(); + if (account.getEmail().equals(ae.getEmail()) && emailCount <= 1) { + throw new IllegalArgumentException("不能解绑唯一邮箱"); + } + accountEmailMapper.deleteById(emailId); + } + + @Override + public List listEmails(Long accountId) { + Account account = accountMapper.selectById(accountId); + if (account == null) return Collections.emptyList(); + List emails = accountEmailMapper.selectByAccountId(accountId) + .stream().map(AccountEmail::getEmail).collect(Collectors.toList()); + // 确保主邮箱在列表首位 + if (!emails.contains(account.getEmail())) { + emails.add(0, account.getEmail()); + } + return emails; + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) sb.append(String.format("%02x", b)); + return sb.toString(); + } + /** * 注册时关联匿名统计记录 */ diff --git a/par-core/src/main/resources/db/migration/V3__api_key_multi_email.sql b/par-core/src/main/resources/db/migration/V3__api_key_multi_email.sql new file mode 100644 index 0000000..250dbb9 --- /dev/null +++ b/par-core/src/main/resources/db/migration/V3__api_key_multi_email.sql @@ -0,0 +1,29 @@ +-- PAR (PT Adapter Registry) Schema - Phase 3 +-- Flyway Migration V3: API Key auth + multi-email support + +-- accounts 表新增 api_key + api_secret +ALTER TABLE accounts + ADD api_key VARCHAR(64) NULL UNIQUE COMMENT 'API 密钥(public)', + ADD api_secret VARCHAR(128) NULL COMMENT 'API 密钥(secret,用于 HMAC 签名)'; + +-- 为已有管理员账号生成默认 key +UPDATE accounts SET + api_key = CONCAT('par_', LOWER(LEFT(MD5(RAND()), 16))), + api_secret = LEFT(SHA2(CONCAT(email, RAND()), 256), 64) +WHERE api_key IS NULL AND trust_level = 2; + +-- 多邮箱关联表 +CREATE TABLE account_emails ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + account_id BIGINT UNSIGNED NOT NULL COMMENT '关联账户ID', + email VARCHAR(255) NOT NULL COMMENT '邮箱地址', + verified TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否已验证', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '绑定时间', + PRIMARY KEY (id), + UNIQUE KEY uk_email (email), + KEY idx_account (account_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账户多邮箱关联表'; + +-- 现有邮箱迁移到关联表 +INSERT INTO account_emails (account_id, email, verified) + SELECT id, email, 1 FROM accounts WHERE deleted = 0; diff --git a/web/src/api/index.js b/web/src/api/index.js index 45d8c97..a7b03ac 100644 --- a/web/src/api/index.js +++ b/web/src/api/index.js @@ -44,7 +44,8 @@ export default { }, users: { list: () => api.get('/admin/accounts'), - setTrustLevel: (id, level) => api.post(`/admin/accounts/${id}/trust-level`, null, { params: { level } }) + setTrustLevel: (id, level) => api.post(`/admin/accounts/${id}/trust-level`, null, { params: { level } }), + genApiKey: (id) => api.post(`/admin/accounts/${id}/api-key`) }, health: () => api.get('/health', { baseURL: '' }) } diff --git a/web/src/views/Users.vue b/web/src/views/Users.vue index ea9d622..55e17a3 100644 --- a/web/src/views/Users.vue +++ b/web/src/views/Users.vue @@ -3,37 +3,69 @@

用户管理

- - - - + + + + + + + - + - - + + + + + + Secret 仅在生成时显示一次,请立即复制保存! + + + + + + + + + + + + + + + + @@ -63,6 +95,8 @@ const loading = ref(false) const dialogVisible = ref(false) const saving = ref(false) const levelForm = reactive({ id: null, email: '', level: '' }) +const keyDialogVisible = ref(false) +const keyResult = reactive({ apiKey: '', apiSecret: '' }) const trustLevelType = (level) => { const map = { MEMBER: '', TRUSTED: 'warning', ADMIN: 'danger' } @@ -86,6 +120,22 @@ const fetchUsers = async () => { } } +const handleGenKey = async (row) => { + try { + const res = await api.users.genApiKey(row.id) + keyResult.apiKey = res.data.apiKey + keyResult.apiSecret = res.data.apiSecret + keyDialogVisible.value = true + fetchUsers() + } catch (e) { + ElMessage.error(e.response?.data?.message || '生成失败') + } +} + +const copyText = (text) => { + navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制')) +} + const handleSetLevel = (row) => { levelForm.id = row.id levelForm.email = row.email